mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(auth): let phone-gated accounts set the check aside (#2478)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user