feat(auth): let phone-gated accounts set the check aside (#2478)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 01:11:24 +02:00
1 parent e12b60078a
commit 9b3773c1e6
127 files changed
+6233 -1410

No files matched your search

@@ -8,6 +8,7 @@ import {
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
imposePhoneRequirements,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
@@ -464,7 +465,13 @@ export class AdminUserSecurityService {
(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 keepsPromotion =
(currentFlags & PHONE_GATE_PROMOTED_FROM_DEFERRAL) !== 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) |
(keepsPromotion ? PHONE_GATE_PROMOTED_FROM_DEFERRAL : 0);
const updatedUser = await userRepository.patchUpsert(
userId,
{
@@ -1,10 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
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 {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {setInjectedRegistrationRiskEvaluator} from '../../middleware/ServiceMiddleware';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import {
authorizeOAuth2,
createOAuth2Application,
exchangeOAuth2AuthorizationCode,
} from '../../oauth/tests/OAuthTestUtils';
import {PHONE_GATE_ESCAPE_MAX_GUILDS} from '../../risk/DeferredPhoneGate';
import {
RecommendedAction,
RiskConfidence,
@@ -13,6 +23,7 @@ import {
type RiskLevel as RiskLevelType,
} from '../../risk/RiskTypes';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {NoopGatewayService} from '../../test/NoopGatewayService';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import type {IRegistrationRiskEvaluator} from '../services/IRegistrationRiskEvaluator';
import {
@@ -74,6 +85,27 @@ async function readFlags(userId: string): Promise<number> {
return user?.suspiciousActivityFlags ?? 0;
}
async function readGuildIds(userId: string): Promise<Array<string>> {
const {GuildRepository} = await import('../../guild/repositories/GuildRepository');
const {createUserID} = await import('../../BrandedTypes');
const guilds = await new GuildRepository().listUserGuilds(createUserID(BigInt(userId)));
return guilds.map((guild) => guild.id.toString());
}
const ESCAPE_PATH = '/users/@me/required-actions/phone-gate-escape';
const RATE_LIMIT_HEADER = 'x-fluxer-test-enable-rate-limits';
interface EscapeSubject {
userId: string;
token: string;
}
interface EscapePreviewResponse {
available: boolean;
guilds: Array<{id: string; name: string}>;
owned_guilds: Array<{id: string; name: string}>;
}
describe('Deferred phone verification gate', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
@@ -208,4 +240,452 @@ describe('Deferred phone verification gate', () => {
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
describe('phone gate escape', () => {
async function configurePhoneGate(
overrides: {
deferred_phone_gate_enabled?: boolean;
deferred_phone_gate_member_threshold?: number;
deferred_phone_gate_window_hours?: number;
single_community_enabled?: boolean;
single_community_guild_id?: string | null;
} = {},
): Promise<void> {
await getInstanceConfigRepository().setInstancePolicyConfig({
deferred_phone_gate_enabled: true,
deferred_phone_gate_member_threshold: 1,
deferred_phone_gate_window_hours: 24,
...overrides,
});
}
async function registerDeferredUser(prefix: string): Promise<EscapeSubject> {
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Gate Escape',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
setInjectedRegistrationRiskEvaluator(undefined);
return {userId: registration.user_id, token: registration.token};
}
async function addFillerMember(inviteCode: string): Promise<void> {
const filler = await createTestAccount(harness);
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
}
async function makeDiscoverable(guildId: string): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/guilds/${guildId}/features`)
.body({add_features: ['DISCOVERABLE']})
.execute();
}
async function setSuspiciousFlags(userId: string, flags: number): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/security-flags`)
.body({suspicious_activity_flags: flags})
.execute();
}
async function promoteThroughRefusedJoin(subject: EscapeSubject, inviteCode: string): Promise<void> {
await createBuilder(harness, subject.token).post(`/invites/${inviteCode}`).expect(403).execute();
}
async function createPromotedSubject(prefix: string): Promise<EscapeSubject> {
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
const subject = await registerDeferredUser(prefix);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
return subject;
}
function previewEscape(subject: EscapeSubject) {
return createBuilder<EscapePreviewResponse>(harness, subject.token).get(ESCAPE_PATH).expect(200).execute();
}
function executeEscape(subject: EscapeSubject) {
return createBuilder<{required_actions: Array<string> | null}>(harness, subject.token)
.post(ESCAPE_PATH)
.body({})
.expect(200)
.execute();
}
function expectEscapeRefused(subject: EscapeSubject) {
return createBuilder(harness, subject.token)
.post(ESCAPE_PATH)
.body({})
.expect(400, 'PHONE_GATE_ESCAPE_UNAVAILABLE')
.execute();
}
it('answers both escape routes for an account every ordinary route refuses', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_reach');
await createBuilder(harness, subject.token)
.get('/users/@me/guilds')
.expect(403, 'ACCOUNT_SUSPICIOUS_ACTIVITY')
.execute();
const preview = await previewEscape(subject);
expect(preview.available).toBe(true);
await executeEscape(subject);
});
it('records the promotion without leaking the bit into the client projection', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_bit');
const flags = await readFlags(subject.userId);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
const me = await createBuilder<{required_actions: Array<string> | null; suspicious_activity_flags?: number}>(
harness,
subject.token,
)
.get('/users/@me')
.expect(200)
.execute();
expect(me.required_actions).toEqual(['REQUIRE_VERIFIED_PHONE']);
expect(me.suspicious_activity_flags).toBeUndefined();
});
it('unlocks an account that is in no community at all', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_empty');
expect(await readGuildIds(subject.userId)).toEqual([]);
const preview = await previewEscape(subject);
expect(preview).toEqual({available: true, guilds: [], owned_guilds: []});
const updated = await executeEscape(subject);
expect(updated.required_actions ?? []).toEqual([]);
const me = await createBuilder<{required_actions: Array<string> | null}>(harness, subject.token)
.get('/users/@me')
.expect(200)
.execute();
expect(me.required_actions ?? []).toEqual([]);
await createBuilder(harness, subject.token).get('/users/@me/guilds').expect(200).execute();
const flags = await readFlags(subject.userId);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
it('leaves the qualifying community, unlocks the account and dispatches a single user update', async () => {
await configurePhoneGate();
const member = await createGuildWithInvite(harness);
const subject = await registerDeferredUser('escape_leave');
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
const preview = await previewEscape(subject);
expect(preview.guilds.map((guild) => guild.id)).toEqual([member.guildId]);
expect(preview.owned_guilds).toEqual([]);
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
try {
const updated = await executeEscape(subject);
expect(updated.required_actions ?? []).toEqual([]);
const userUpdates = dispatchSpy.mock.calls.filter(
([params]) => params.event === 'USER_UPDATE' && params.userId.toString() === subject.userId,
);
expect(userUpdates).toHaveLength(1);
} finally {
dispatchSpy.mockRestore();
}
expect(await readGuildIds(subject.userId)).toEqual([]);
const flags = await readFlags(subject.userId);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
});
it('applies the member threshold strictly, so a community sitting on it is kept', async () => {
await configurePhoneGate({deferred_phone_gate_member_threshold: 2});
const onThreshold = await createGuildWithInvite(harness);
const aboveThreshold = await createGuildWithInvite(harness);
await addFillerMember(aboveThreshold.inviteCode);
const subject = await registerDeferredUser('escape_bound');
await createBuilder(harness, subject.token).post(`/invites/${onThreshold.inviteCode}`).expect(200).execute();
await createBuilder(harness, subject.token).post(`/invites/${aboveThreshold.inviteCode}`).expect(200).execute();
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
await addFillerMember(gate.inviteCode);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
const preview = await previewEscape(subject);
expect(preview.guilds.map((guild) => guild.id)).toEqual([aboveThreshold.guildId]);
await executeEscape(subject);
expect(await readGuildIds(subject.userId)).toEqual([onThreshold.guildId]);
});
it('leaves a discoverable community that sits far below the member threshold', async () => {
await configurePhoneGate({deferred_phone_gate_member_threshold: 50});
const discoverable = await createGuildWithInvite(harness);
const subject = await registerDeferredUser('escape_disc');
await createBuilder(harness, subject.token).post(`/invites/${discoverable.inviteCode}`).expect(200).execute();
await makeDiscoverable(discoverable.guildId);
const gate = await createGuildWithInvite(harness);
await makeDiscoverable(gate.guildId);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
const preview = await previewEscape(subject);
expect(preview.guilds.map((guild) => guild.id)).toEqual([discoverable.guildId]);
await executeEscape(subject);
expect(await readGuildIds(subject.userId)).toEqual([]);
});
it('keeps a qualifying community the user owns and reports it separately', async () => {
await configurePhoneGate();
const member = await createGuildWithInvite(harness);
const subject = await registerDeferredUser('escape_owned');
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
await createBuilderWithoutAuth(harness)
.post(`/test/users/${subject.userId}/security-flags`)
.body({email_verified: true})
.execute();
const owned = await createBuilder<GuildResponse>(harness, subject.token)
.post('/guilds')
.body({name: `PhoneGateOwned-${Date.now()}`})
.execute();
const ownedInvite = await createBuilder<{code: string}>(harness, subject.token)
.post(`/channels/${owned.system_channel_id}`.concat('/invites'))
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
.execute();
await addFillerMember(ownedInvite.code);
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
const preview = await previewEscape(subject);
expect(preview.guilds.map((guild) => guild.id)).toEqual([member.guildId]);
expect(preview.owned_guilds.map((guild) => guild.id)).toEqual([owned.id]);
const updated = await executeEscape(subject);
expect(updated.required_actions ?? []).toEqual([]);
expect(await readGuildIds(subject.userId)).toEqual([owned.id]);
expect((await readFlags(subject.userId)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
});
it('leaves what it can when the qualifying set exceeds the per-call limit, and finishes on a second call', async () => {
await configurePhoneGate();
const subject = await registerDeferredUser('escape_batch');
for (let index = 0; index < PHONE_GATE_ESCAPE_MAX_GUILDS + 1; index++) {
const guild = await createGuildWithInvite(harness);
await createBuilder(harness, subject.token).post(`/invites/${guild.inviteCode}`).expect(200).execute();
}
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
const preview = await previewEscape(subject);
expect(preview.guilds).toHaveLength(PHONE_GATE_ESCAPE_MAX_GUILDS + 1);
const afterFirst = await executeEscape(subject);
expect(afterFirst.required_actions ?? []).toEqual(['REQUIRE_VERIFIED_PHONE']);
expect(await readGuildIds(subject.userId)).toHaveLength(1);
const flagsAfterFirst = await readFlags(subject.userId);
expect(flagsAfterFirst & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(flagsAfterFirst & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
const afterSecond = await executeEscape(subject);
expect(afterSecond.required_actions ?? []).toEqual([]);
expect(await readGuildIds(subject.userId)).toEqual([]);
const flags = await readFlags(subject.userId);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
});
it('refuses an account whose phone requirement was never deferred', async () => {
await configurePhoneGate();
const member = await createGuildWithInvite(harness);
const account = await createTestAccount(harness);
await createBuilder(harness, account.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
await setSuspiciousFlags(account.userId, SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
const subject: EscapeSubject = {userId: account.userId, token: account.token};
const preview = await previewEscape(subject);
expect(preview).toEqual({available: false, guilds: [], owned_guilds: []});
await expectEscapeRefused(subject);
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
expect(await readFlags(subject.userId)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
});
it('refuses while the gate is switched off, leaving flags and memberships untouched', async () => {
await configurePhoneGate();
const member = await createGuildWithInvite(harness);
const subject = await registerDeferredUser('escape_off');
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
const flagsBefore = await readFlags(subject.userId);
await configurePhoneGate({deferred_phone_gate_enabled: false});
const preview = await previewEscape(subject);
expect(preview.available).toBe(false);
await expectEscapeRefused(subject);
expect(await readFlags(subject.userId)).toBe(flagsBefore);
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
});
it('refuses in single-community mode and leaves the designated community alone', async () => {
await configurePhoneGate();
const member = await createGuildWithInvite(harness);
const subject = await registerDeferredUser('escape_single');
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
const gate = await createGuildWithInvite(harness);
await addFillerMember(gate.inviteCode);
await promoteThroughRefusedJoin(subject, gate.inviteCode);
await configurePhoneGate({single_community_enabled: true, single_community_guild_id: member.guildId});
const preview = await previewEscape(subject);
expect(preview.available).toBe(false);
await expectEscapeRefused(subject);
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
});
it('refuses an account that is still deferred and has never been locked out', async () => {
await configurePhoneGate();
const member = await createGuildWithInvite(harness);
const subject = await registerDeferredUser('escape_defer');
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
const flagsBefore = await readFlags(subject.userId);
const preview = await previewEscape(subject);
expect(preview).toEqual({available: false, guilds: [], owned_guilds: []});
await expectEscapeRefused(subject);
expect(await readFlags(subject.userId)).toBe(flagsBefore);
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
});
it('refuses a promoted account that also carries the inbound-SMS tier', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_inbound');
const promotedFlags = await readFlags(subject.userId);
await setSuspiciousFlags(
subject.userId,
promotedFlags | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
);
const preview = await previewEscape(subject);
expect(preview.available).toBe(false);
await expectEscapeRefused(subject);
});
it('closes itself after a successful escape', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_twice');
await executeEscape(subject);
const flagsAfterFirst = await readFlags(subject.userId);
await expectEscapeRefused(subject);
expect(await readFlags(subject.userId)).toBe(flagsAfterFirst);
const preview = await previewEscape(subject);
expect(preview.available).toBe(false);
});
it('leaves a remaining email requirement in place', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_email');
const promotedFlags = await readFlags(subject.userId);
await setSuspiciousFlags(subject.userId, promotedFlags | SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
const updated = await executeEscape(subject);
expect(updated.required_actions ?? []).toEqual(['REQUIRE_VERIFIED_EMAIL']);
const me = await createBuilder<{required_actions: Array<string> | null}>(harness, subject.token)
.get('/users/@me')
.expect(200)
.execute();
expect(me.required_actions ?? []).toEqual(['REQUIRE_VERIFIED_EMAIL']);
});
it('restores the deferral, so a qualifying join re-promotes inside the window and not outside it', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_rearm');
await executeEscape(subject);
const insideWindow = await createGuildWithInvite(harness);
await addFillerMember(insideWindow.inviteCode);
await promoteThroughRefusedJoin(subject, insideWindow.inviteCode);
const repromoted = await readFlags(subject.userId);
expect(repromoted & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(repromoted & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
await executeEscape(subject);
await configurePhoneGate({deferred_phone_gate_window_hours: 0.0001});
const outsideWindow = await createGuildWithInvite(harness);
await addFillerMember(outsideWindow.inviteCode);
await createBuilder(harness, subject.token).post(`/invites/${outsideWindow.inviteCode}`).expect(200).execute();
const flags = await readFlags(subject.userId);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
});
it('refuses bot tokens and OAuth2 bearer tokens on both routes', async () => {
await configurePhoneGate();
const owner = await createTestAccount(harness);
const redirectURI = 'https://example.com/callback';
const application = await createOAuth2Application(harness, owner, {redirect_uris: [redirectURI]});
const {code} = await authorizeOAuth2(harness, owner.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const {access_token: accessToken} = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code,
redirect_uri: redirectURI,
});
for (const token of [`Bot ${application.bot.token}`, `Bearer ${accessToken}`]) {
await createBuilder(harness, token).get(ESCAPE_PATH).expect(403).execute();
await createBuilder(harness, token).post(ESCAPE_PATH).body({}).expect(403).execute();
}
});
it('spends the execute budget only on execute calls', async () => {
await configurePhoneGate();
const subject = await createPromotedSubject('escape_limit');
for (let index = 0; index < 3; index++) {
await createBuilder(harness, subject.token)
.get(ESCAPE_PATH)
.header(RATE_LIMIT_HEADER, 'true')
.expect(200)
.execute();
}
for (let index = 0; index < 5; index++) {
const {response} = await createBuilder(harness, subject.token)
.post(ESCAPE_PATH)
.body({})
.header(RATE_LIMIT_HEADER, 'true')
.executeRaw();
expect(response.status).toBe(index === 0 ? 200 : 400);
}
const {response} = await createBuilder(harness, subject.token)
.post(ESCAPE_PATH)
.body({})
.header(RATE_LIMIT_HEADER, 'true')
.executeRaw();
expect(response.status).toBe(429);
});
});
});
+180
View File
@@ -21048,6 +21048,142 @@
}
}
},
"/users/@me/required-actions/phone-gate-escape": {
"get": {
"operationId": "get_phone_gate_escape",
"summary": "Preview setting the deferred phone check aside",
"tags": ["Users"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PhoneGateEscapePreviewResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Preview setting the deferred phone check aside"}},
"description": "Reports whether this account can set a deferred phone verification requirement aside, and which communities would be left if it did. Returns available false with empty lists for any account outside that state.",
"security": [{"sessionToken": []}]
},
"post": {
"operationId": "execute_phone_gate_escape",
"summary": "Set the deferred phone check aside",
"tags": ["Users"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPrivateResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Set the deferred phone check aside"}},
"description": "Leaves the communities that trigger the deferred phone verification check and restores the deferral, so the account works normally again. Communities the user owns are kept, and a run that hits the per-call community limit leaves what it can and can be repeated. Returns the updated private user object.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmptyBodyRequest"}}}
}
}
},
"/users/@me/saved-messages": {
"get": {
"operationId": "list_saved_messages",
@@ -23556,6 +23692,7 @@
"PASSKEYS_DISABLED",
"PHONE_ADD_NOT_ELIGIBLE",
"PHONE_ALREADY_USED",
"PHONE_GATE_ESCAPE_UNAVAILABLE",
"PHONE_RATE_LIMIT_EXCEEDED",
"PHONE_VERIFICATION_REQUIRED",
"PREMIUM_PURCHASE_BLOCKED",
@@ -23864,6 +24001,7 @@
"Passkeys are disabled.",
"You are not eligible to add a phone number to your account.",
"Phone number is already in use.",
"Setting the phone verification check aside is not available for this account.",
"Phone rate limit exceeded.",
"Phone verification is required.",
"No active subscription.",
@@ -34160,6 +34298,48 @@
},
"required": ["nickname"]
},
"PhoneGateEscapePreviewResponse": {
"type": "object",
"properties": {
"available": {
"type": "boolean",
"description": "Whether this account can set the deferred phone verification check aside right now"
},
"guilds": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)$",
"description": "The unique identifier (snowflake) for the community"
},
"name": {"type": "string", "description": "The community name"}
},
"required": ["id", "name"]
},
"description": "Communities that trigger the phone check and will be left when the escape runs"
},
"owned_guilds": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)$",
"description": "The unique identifier (snowflake) for the community"
},
"name": {"type": "string", "description": "The community name"}
},
"required": ["id", "name"]
},
"description": "Communities that trigger the phone check but are owned by this user, so they are kept"
}
},
"required": ["available", "guilds", "owned_guilds"]
},
"SavedMessageEntryListResponse": {
"type": "array",
"items": {"$ref": "#/components/schemas/SavedMessageEntryResponse"}
@@ -84,6 +84,14 @@ export const UserRateLimitConfigs = {
bucket: 'user:account:delete',
config: {limit: 5, windowMs: ms('1 hour')},
} as RouteRateLimitConfig,
USER_PHONE_GATE_ESCAPE_PREVIEW: {
bucket: 'user:phone_gate_escape:preview',
config: {limit: 20, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_PHONE_GATE_ESCAPE: {
bucket: 'user:phone_gate_escape:execute',
config: {limit: 5, windowMs: ms('1 hour')},
} as RouteRateLimitConfig,
USER_DATA_HARVEST: {
bucket: 'user:data:harvest',
config: {limit: 5, windowMs: ms('30 minutes')},
@@ -1,13 +1,23 @@
// 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 {
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
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 {
canEscapePhoneGate,
type DeferredPhoneGateConfig,
evaluateDeferredPhoneGate,
guildTriggersPhoneGate,
restorePhoneGateDeferral,
} from './DeferredPhoneGate';
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
const CONFIG: DeferredPhoneGateConfig = {
@@ -48,6 +58,7 @@ describe('evaluateDeferredPhoneGate', () => {
if (!outcome.applies) return;
expect(outcome.flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(outcome.flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
expect(outcome.flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
});
it('applies to a large non-discoverable guild inside the window', () => {
@@ -124,7 +135,9 @@ describe('evaluateDeferredPhoneGate', () => {
expect(outcome.applies).toBe(true);
if (!outcome.applies) return;
expect(outcome.flags).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION |
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
);
});
});
@@ -152,3 +165,78 @@ describe('guildTriggersPhoneGate', () => {
expect(guildTriggersPhoneGate(createGuild({memberCount: 50}), 50)).toBe(false);
});
});
describe('restorePhoneGateDeferral', () => {
it('sets the marker, clears the promotion bit and preserves the stored phone requirement', () => {
const promoted = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
expect(restorePhoneGateDeferral(promoted)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
);
});
it('preserves every unrelated requirement bit', () => {
const promoted =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE |
PHONE_GATE_PROMOTED_FROM_DEFERRAL;
const restored = restorePhoneGateDeferral(promoted);
expect(restored & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL).not.toBe(0);
expect(restored & SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE).not.toBe(0);
expect(restored & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
expect(restored & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
});
it('is idempotent when applied twice', () => {
const promoted = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
const once = restorePhoneGateDeferral(promoted);
expect(restorePhoneGateDeferral(once)).toBe(once);
});
});
describe('canEscapePhoneGate', () => {
const PROMOTED = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
it('accepts a promoted account while the gate is on', () => {
expect(canEscapePhoneGate(createUser({suspiciousActivityFlags: PROMOTED}), 'ok')).toBe(true);
});
it('accepts a promoted account carrying a re-verification tier instead', () => {
const promotedReverify = SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
expect(canEscapePhoneGate(createUser({suspiciousActivityFlags: promotedReverify}), 'ok')).toBe(true);
});
it('refuses an account with no promotion bit, whatever its flag shape looks like', () => {
const imposed = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
expect(canEscapePhoneGate(createUser({suspiciousActivityFlags: imposed}), 'ok')).toBe(false);
});
it('refuses an account still carrying the deferral marker', () => {
const stillDeferred =
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
DEFERRED_PHONE_ON_COMMUNITY_JOIN |
PHONE_GATE_PROMOTED_FROM_DEFERRAL;
expect(canEscapePhoneGate(createUser({suspiciousActivityFlags: stillDeferred}), 'ok')).toBe(false);
});
it('refuses an account carrying the inbound-SMS tier, which is never suppressed at read time', () => {
const inbound = PROMOTED | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION;
expect(canEscapePhoneGate(createUser({suspiciousActivityFlags: inbound}), 'ok')).toBe(false);
});
it('refuses an account that already has a verified phone', () => {
expect(canEscapePhoneGate(createUser({hasVerifiedPhone: true, suspiciousActivityFlags: PROMOTED}), 'ok')).toBe(
false,
);
});
it('refuses an account with no deferrable phone requirement left to suppress', () => {
const emailOnly = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
expect(canEscapePhoneGate(createUser({suspiciousActivityFlags: emailOnly}), 'ok')).toBe(false);
});
it('refuses whenever the gate is not readable as on, since the marker would suppress nothing', () => {
const user = createUser({suspiciousActivityFlags: PROMOTED});
expect(canEscapePhoneGate(user, 'disabled')).toBe(false);
expect(canEscapePhoneGate(user, 'unreadable')).toBe(false);
});
});
+38 -5
View File
@@ -5,6 +5,7 @@ import {
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
NEVER_DEFERRABLE_PHONE_FLAGS,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
} from '@fluxer/constants/src/UserConstants';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {ms} from 'itty-time';
@@ -22,16 +23,20 @@ export interface DeferredPhoneGateConfig {
export const DEFAULT_PHONE_GATE_MEMBER_THRESHOLD = 50;
export const PHONE_GATE_ESCAPE_MAX_GUILDS = 25;
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 type DeferredPhoneGateStatus = 'ok' | 'disabled' | 'unreadable';
interface DeferredPhoneGateConfigResult {
status: DeferredPhoneGateStatus;
config: DeferredPhoneGateConfig;
}
export async function getDeferredPhoneGateConfig(): Promise<DeferredPhoneGateConfigResult> {
try {
@@ -64,6 +69,30 @@ export async function deferPhoneFlagsUntilCommunityJoin(flagBits: number): Promi
return flagBits | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
}
export function restorePhoneGateDeferral(flagBits: number): number {
return (flagBits | DEFERRED_PHONE_ON_COMMUNITY_JOIN) & ~PHONE_GATE_PROMOTED_FROM_DEFERRAL;
}
export function canEscapePhoneGate(user: User, gateStatus: DeferredPhoneGateStatus): boolean {
if (gateStatus !== 'ok') {
return false;
}
if (user.hasVerifiedPhone) {
return false;
}
const flagBits = user.suspiciousActivityFlags ?? 0;
if ((flagBits & DEFERRABLE_PHONE_FLAGS) === 0) {
return false;
}
if ((flagBits & NEVER_DEFERRABLE_PHONE_FLAGS) !== 0) {
return false;
}
if ((flagBits & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0) {
return false;
}
return (flagBits & PHONE_GATE_PROMOTED_FROM_DEFERRAL) !== 0;
}
export function guildTriggersPhoneGate(guild: Guild, memberThreshold: number): boolean {
return guild.features.has(GuildFeatures.DISCOVERABLE) || guild.memberCount > memberThreshold;
}
@@ -90,5 +119,9 @@ export function evaluateDeferredPhoneGate(
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};
return {
applies: true,
flags:
((user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN) | PHONE_GATE_PROMOTED_FROM_DEFERRAL,
};
}
@@ -3,6 +3,7 @@
import {
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
imposePhoneRequirements,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
PremiumFlags,
SuspiciousActivityFlags,
UserPremiumTypes,
@@ -103,6 +104,41 @@ describe('deferred phone gate marker', () => {
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(getRequiredActions(createUser({suspiciousActivityFlags: imposed}))).toEqual(['REQUIRE_VERIFIED_EMAIL']);
});
it('revokes escape eligibility once another subsystem imposes the phone requirement directly', () => {
const promoted = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
const imposed = imposePhoneRequirements(promoted, SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE);
expect(imposed & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(getRequiredActions(createUser({suspiciousActivityFlags: imposed}))).toEqual(['REQUIRE_REVERIFIED_PHONE']);
});
it('keeps escape eligibility when a non-phone requirement is imposed', () => {
const promoted = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL;
const imposed = imposePhoneRequirements(promoted, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
expect(imposed & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
expect(getRequiredActions(createUser({suspiciousActivityFlags: imposed}))).toEqual([
'REQUIRE_VERIFIED_EMAIL',
'REQUIRE_VERIFIED_PHONE',
]);
});
it('keeps both bookkeeping bits when a non-phone requirement is imposed on a still-deferred account', () => {
const deferred =
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
DEFERRED_PHONE_ON_COMMUNITY_JOIN |
PHONE_GATE_PROMOTED_FROM_DEFERRAL;
const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(imposed & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
});
it('never turns the promotion bit into a requirement or an enforceable flag of its own', () => {
const promotedOnly = createUser({suspiciousActivityFlags: PHONE_GATE_PROMOTED_FROM_DEFERRAL});
expect(getRequiredActions(promotedOnly)).toEqual([]);
expect(getEffectiveSuspiciousFlags(promotedOnly)).toBe(0);
const promoted = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | PHONE_GATE_PROMOTED_FROM_DEFERRAL,
});
expect(getRequiredActions(promoted)).toEqual(['REQUIRE_VERIFIED_PHONE']);
expect(getEffectiveSuspiciousFlags(promoted)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
});
it('yields no enforceable requirement for an account without an email, so the gate must not promote it', () => {
const user = createUser({
email: null,
@@ -46,6 +46,7 @@ import {
PasswordChangeCompleteResponse,
PasswordChangeStartResponse,
PasswordChangeVerifyResponse,
PhoneGateEscapePreviewResponse,
PreloadMessagesResponse,
PushSubscribeResponse,
PushSubscriptionsListResponse,
@@ -1174,6 +1175,55 @@ export function UserAccountController(app: HonoApp) {
return ctx.body(null, 202);
},
);
app.get(
'/users/@me/required-actions/phone-gate-escape',
RateLimitMiddleware(RateLimitConfigs.USER_PHONE_GATE_ESCAPE_PREVIEW),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'get_phone_gate_escape',
summary: 'Preview setting the deferred phone check aside',
responseSchema: PhoneGateEscapePreviewResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Reports whether this account can set a deferred phone verification requirement aside, and which communities would be left if it did. Returns available false with empty lists for any account outside that state.',
}),
async (ctx) => {
const {available, guilds, ownedGuilds} = await ctx
.get('userService')
.accountService.lifecycleService.previewPhoneGateEscape(ctx.get('user').id);
return ctx.json({
available,
guilds: guilds.map((guild) => ({id: guild.id.toString(), name: guild.name})),
owned_guilds: ownedGuilds.map((guild) => ({id: guild.id.toString(), name: guild.name})),
});
},
);
app.post(
'/users/@me/required-actions/phone-gate-escape',
RateLimitMiddleware(RateLimitConfigs.USER_PHONE_GATE_ESCAPE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', EmptyBodyRequest),
OpenAPI({
operationId: 'execute_phone_gate_escape',
summary: 'Set the deferred phone check aside',
responseSchema: UserPrivateResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Leaves the communities that trigger the deferred phone verification check and restores the deferral, so the account works normally again. Communities the user owns are kept, and a run that hits the per-call community limit leaves what it can and can be repeated. Returns the updated private user object.',
}),
async (ctx) => {
const {user} = await ctx
.get('userService')
.accountService.lifecycleService.executePhoneGateEscape(ctx.get('user').id);
return ctx.json(mapUserToPrivateResponse(user));
},
);
app.post(
'/users/@me/terms-acceptance',
RateLimitMiddleware(RateLimitConfigs.USER_UPDATE_SELF),
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {DEFERRABLE_PHONE_FLAGS, DEFERRED_PHONE_ON_COMMUNITY_JOIN, UserFlags} from '@fluxer/constants/src/UserConstants';
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import {UserOwnsGuildsError} from '@fluxer/errors/src/domains/guild/UserOwnsGuildsError';
import {PhoneGateEscapeUnavailableError} from '@fluxer/errors/src/domains/user/PhoneGateEscapeUnavailableError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IEmailService} from '@pkgs/email/src/IEmailService';
import {ms} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
@@ -11,21 +14,57 @@ import * as AuthSession from '../../auth/AuthSession';
import type {UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '../../guild/services/GuildService';
import type {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
import {Logger} from '../../Logger';
import type {Guild} from '../../models/Guild';
import type {User} from '../../models/User';
import {
canEscapePhoneGate,
type DeferredPhoneGateStatus,
getDeferredPhoneGateConfig,
guildTriggersPhoneGate,
PHONE_GATE_ESCAPE_MAX_GUILDS,
restorePhoneGateDeferral,
} from '../../risk/DeferredPhoneGate';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import {getEffectiveSuspiciousFlags} from '../UserHelpers';
import {hasPartialUserFieldsChanged} from '../UserMappers';
import {reschedulePendingDeletion} from './PendingDeletionCoordinator';
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
const WRITE_RESTORED_DEFERRAL_ATTEMPTS = 3;
interface UserAccountLifecycleServiceDeps {
apiContext: ApiContext;
userAccountRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
guildService: GuildService;
emailService: IEmailService;
updatePropagator: UserAccountUpdatePropagator;
kvDeletionQueue: KVAccountDeletionQueueService;
}
interface PhoneGateEscapePlan {
available: boolean;
leavable: Array<Guild>;
owned: Array<Guild>;
gateStatus: DeferredPhoneGateStatus;
threshold: number;
refusedReason: 'ineligible' | 'not_locked' | null;
}
interface PhoneGateEscapePreview {
available: boolean;
guilds: Array<Guild>;
ownedGuilds: Array<Guild>;
}
interface PhoneGateEscapeResult {
user: User;
remainingGuildCount: number;
}
export class UserAccountLifecycleService {
constructor(private readonly deps: UserAccountLifecycleServiceDeps) {}
@@ -94,4 +133,151 @@ export class UserAccountLifecycleService {
}
}
}
private async buildPhoneGateEscapePlan(user: User): Promise<PhoneGateEscapePlan> {
const {status, config} = await getDeferredPhoneGateConfig();
const refused = {
available: false,
leavable: [],
owned: [],
gateStatus: status,
threshold: config.memberThreshold,
};
if (!canEscapePhoneGate(user, status)) {
return {...refused, refusedReason: 'ineligible'};
}
if (getEffectiveSuspiciousFlags(user) === 0) {
return {...refused, refusedReason: 'not_locked'};
}
const guilds = await this.deps.guildRepository.listUserGuilds(user.id);
const qualifying = guilds.filter((guild) => guildTriggersPhoneGate(guild, config.memberThreshold));
return {
available: true,
leavable: qualifying.filter((guild) => guild.ownerId !== user.id),
owned: qualifying.filter((guild) => guild.ownerId === user.id),
gateStatus: status,
threshold: config.memberThreshold,
refusedReason: null,
};
}
async previewPhoneGateEscape(userId: UserID): Promise<PhoneGateEscapePreview> {
const user = await this.deps.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const plan = await this.buildPhoneGateEscapePlan(user);
Logger.debug(
{
userId: userId.toString(),
available: plan.available,
refusedReason: plan.refusedReason,
gateStatus: plan.gateStatus,
qualifyingCount: plan.leavable.length + plan.owned.length,
},
'deferred_phone_gate.escape_previewed',
);
return {available: plan.available, guilds: plan.leavable, ownedGuilds: plan.owned};
}
async executePhoneGateEscape(userId: UserID): Promise<PhoneGateEscapeResult> {
const startedAt = Date.now();
const user = await this.deps.userAccountRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const plan = await this.buildPhoneGateEscapePlan(user);
const baseContext = {
userId: userId.toString(),
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
gateStatus: plan.gateStatus,
threshold: plan.threshold,
};
if (!plan.available) {
Logger.info({...baseContext, reason: plan.refusedReason}, 'deferred_phone_gate.escape_refused');
throw new PhoneGateEscapeUnavailableError();
}
const batch = plan.leavable.slice(0, PHONE_GATE_ESCAPE_MAX_GUILDS);
const remainingGuildCount = plan.leavable.length - batch.length;
const leftGuildIds: Array<string> = [];
const skippedGuildIds: Array<string> = [];
for (const guild of batch) {
try {
await this.deps.guildService.members.leaveGuild({userId: user.id, guildId: guild.id});
leftGuildIds.push(guild.id.toString());
} catch (error) {
if (error instanceof UnknownGuildMemberError) {
skippedGuildIds.push(guild.id.toString());
continue;
}
Logger.warn(
{
...baseContext,
leftGuildIds,
skippedGuildIds,
unattemptedCount: batch.length - leftGuildIds.length - skippedGuildIds.length,
error,
},
'deferred_phone_gate.escape_failed',
);
throw error;
}
}
if (remainingGuildCount > 0) {
Logger.info(
{...baseContext, leftGuildIds, skippedGuildIds, remainingGuildCount},
'deferred_phone_gate.escape_batch_completed',
);
return {user, remainingGuildCount};
}
const updated = await this.writeRestoredDeferral(userId);
if ((getEffectiveSuspiciousFlags(updated) & DEFERRABLE_PHONE_FLAGS) !== 0) {
Logger.warn(
{...baseContext, flagsAfter: updated.suspiciousActivityFlags ?? 0},
'deferred_phone_gate.escape_ineffective',
);
}
try {
await this.deps.updatePropagator.dispatchUserUpdate(updated);
} catch (error) {
Logger.warn({...baseContext, error}, 'deferred_phone_gate.escape_dispatch_failed');
}
Logger.info(
{
...baseContext,
leftGuildIds,
skippedGuildIds,
keptOwnedGuildIds: plan.owned.map((guild) => guild.id.toString()),
flagsBefore: user.suspiciousActivityFlags ?? 0,
flagsAfter: updated.suspiciousActivityFlags ?? 0,
durationMs: Date.now() - startedAt,
},
'deferred_phone_gate.escaped',
);
return {user: updated, remainingGuildCount: 0};
}
private async writeRestoredDeferral(userId: UserID): Promise<User> {
for (let attempt = 0; attempt < WRITE_RESTORED_DEFERRAL_ATTEMPTS; attempt++) {
const current = await this.deps.userAccountRepository.findUnique(userId);
if (!current) {
throw new UnknownUserError();
}
if (((current.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0) {
return current;
}
try {
return await this.deps.userAccountRepository.patchUpsert(
current.id,
{suspicious_activity_flags: restorePhoneGateDeferral(current.suspiciousActivityFlags ?? 0)},
current.toRow(),
);
} catch (error) {
if (attempt === WRITE_RESTORED_DEFERRAL_ATTEMPTS - 1) {
throw error;
}
}
}
throw new UnknownUserError();
}
}
@@ -131,6 +131,7 @@ export class UserAccountService {
apiContext: this.apiContext,
userAccountRepository,
guildRepository,
guildService,
emailService,
updatePropagator: this.updatePropagator,
kvDeletionQueue,