From 2df82b2b5e3011b21e7387fce0daf5699f7ab0ba Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 1 Oct 2026 20:33:02 +0200 Subject: [PATCH] fix(guild): treat very high as high without phone verification (#3095) --- deploy/self-hosting/.env.example | 3 + deploy/self-hosting/docker-compose.yml | 1 + fluxer_api/src/api/Config.test.ts | 38 ++++++++ fluxer_api/src/api/Config.ts | 1 + fluxer_api/src/api/config/APIConfig.ts | 1 + .../guild/services/GuildDiscoveryService.ts | 6 +- .../services/data/GuildOperationsService.ts | 19 +++- .../tests/GuildPhoneVerificationLevel.test.ts | 94 +++++++++++++++++++ .../src/api/instance/InstanceController.ts | 1 + fluxer_api/src/api/openapi/openapi.json | 7 +- .../api/search/guild/GuildSearchSerializer.ts | 2 +- .../user/services/UserAuthRequestService.ts | 12 ++- .../api/utils/GuildVerificationUtils.test.ts | 26 +++++ .../src/api/utils/GuildVerificationUtils.ts | 3 + .../src/features/app/state/RuntimeConfig.ts | 5 + .../modals/guild_tabs/GuildModerationTab.tsx | 13 ++- .../features/guild/state/GuildVerification.ts | 2 + .../VoiceEngineV2AppScreenShareWiring.test.ts | 1 + .../src/content/docs/http-api/guilds.mdx | 4 +- .../src/content/docs/http-api/instance.mdx | 3 + .../http-api/users/phone-verification.mdx | 2 +- .../content/docs/operator/configuration.mdx | 6 +- packages/config/src/MasterConfig.ts | 1 + .../__tests__/EnvironmentOverrides.test.ts | 9 ++ .../src/config_loader/EnvironmentOverrides.ts | 1 + packages/constants/src/GuildConstants.test.ts | 32 +++++++ packages/constants/src/GuildConstants.ts | 11 ++- .../src/domains/instance/InstanceSchemas.ts | 3 + 28 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 fluxer_api/src/api/guild/tests/GuildPhoneVerificationLevel.test.ts diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index d533e9f6f..941e3a567 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -160,6 +160,9 @@ MEILI_MASTER_KEY=CHANGE_ME # api.pwnedpasswords.com. #FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false #FLUXER_BLOCKLIST_FEEDS_ENABLED=false +# Phone verification needs your own responder on the rpc.phone.v1 NATS +# subjects. Off unless turned on. +#FLUXER_PHONE_VERIFICATION_ENABLED=false # A local path, or an s3:// URL read with the S3 credentials of this file. #FLUXER_GEOIP_DB_PATH= diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index abc0f4941..fbede9bf2 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -33,6 +33,7 @@ x-fluxer-env: &fluxer-env FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-} FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-} FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-} + FLUXER_PHONE_VERIFICATION_ENABLED: ${FLUXER_PHONE_VERIFICATION_ENABLED:-} FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-} FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-} diff --git a/fluxer_api/src/api/Config.test.ts b/fluxer_api/src/api/Config.test.ts index a36b44104..049a47cd1 100644 --- a/fluxer_api/src/api/Config.test.ts +++ b/fluxer_api/src/api/Config.test.ts @@ -211,3 +211,41 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => { expect(config.breachedPasswordCheck.enabled).toBe(false); }); }); + +function withPhoneVerification(master: MasterConfig, selfHosted: boolean, enabled?: boolean): MasterConfig { + return { + ...master, + instance: { + ...master.instance, + self_hosted: selfHosted, + phone_verification_enabled: enabled, + }, + }; +} + +describe('buildAPIConfigFromMaster phone verification', () => { + let master: MasterConfig; + beforeAll(async () => { + master = await loadConfig(); + }); + + it('is on by default when the instance is not self-hosted', () => { + expect(buildAPIConfigFromMaster(withPhoneVerification(master, false)).instance.phoneVerificationEnabled).toBe(true); + }); + + it('is off by default on a self-hosted instance', () => { + expect(buildAPIConfigFromMaster(withPhoneVerification(master, true)).instance.phoneVerificationEnabled).toBe(false); + }); + + it('lets a self-hosted operator switch it on', () => { + expect(buildAPIConfigFromMaster(withPhoneVerification(master, true, true)).instance.phoneVerificationEnabled).toBe( + true, + ); + }); + + it('lets an operator switch it off when the instance is not self-hosted', () => { + expect( + buildAPIConfigFromMaster(withPhoneVerification(master, false, false)).instance.phoneVerificationEnabled, + ).toBe(false); + }); +}); diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 97ab5c51d..d8ed4a9aa 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -367,6 +367,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { }, instance: { selfHosted: master.instance.self_hosted, + phoneVerificationEnabled: master.instance.phone_verification_enabled ?? !master.instance.self_hosted, autoJoinInviteCode: master.instance.auto_join_invite_code, visionariesGuildId: master.instance.visionaries_guild_id, visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id, diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 64eddf8f5..705c56203 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -262,6 +262,7 @@ export interface APIConfig { }; instance: { selfHosted: boolean; + phoneVerificationEnabled: boolean; autoJoinInviteCode?: string; visionariesGuildId?: string; visionariesGuildVisionaryRoleId?: string; diff --git a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts index 66d245bc9..39a2dff72 100644 --- a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts +++ b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts @@ -434,7 +434,11 @@ export class GuildDiscoveryService extends IGuildDiscoveryService { member_count: hit.memberCount, online_count: 0, features: hit.features, - verification_level: getEffectiveGuildVerificationLevel(hit.verificationLevel, hit.isDiscoverable), + verification_level: getEffectiveGuildVerificationLevel( + hit.verificationLevel, + hit.isDiscoverable, + Config.instance.phoneVerificationEnabled, + ), })); const total = results.total; if (guilds.length > 0) { diff --git a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts index 84a6029b7..38f49724b 100644 --- a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts +++ b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts @@ -2,6 +2,7 @@ import type {ChannelID, GuildID, RoleID, UserID} from '@app/api/BrandedTypes'; import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import { type ChannelFollowerRemovalCopyMode, @@ -481,6 +482,17 @@ export class GuildOperationsService { ValidationErrorCodes.DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW, ); } + if ( + data.verification_level === GuildVerificationLevel.VERY_HIGH && + data.verification_level !== currentGuild.verificationLevel && + !Config.instance.phoneVerificationEnabled + ) { + throw InputValidationError.fromCode('verification_level', ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE, { + name: 'verification_level', + minValue: GuildVerificationLevel.NONE, + maxValue: GuildVerificationLevel.HIGH, + }); + } const isMfaLevelChange = data.mfa_level !== undefined && data.mfa_level !== currentGuild.mfaLevel; if (isMfaLevelChange) { const isOwner = guildData.owner_id === userId.toString(); @@ -1177,7 +1189,12 @@ export class GuildOperationsService { private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings { return { - verificationLevel: this.clampTemplateSetting(template?.verification_level, 0, 4, 0), + verificationLevel: this.clampTemplateSetting( + template?.verification_level, + GuildVerificationLevel.NONE, + Config.instance.phoneVerificationEnabled ? GuildVerificationLevel.VERY_HIGH : GuildVerificationLevel.HIGH, + GuildVerificationLevel.NONE, + ), explicitContentFilter: this.clampTemplateSetting(template?.explicit_content_filter, 0, 2, 0), defaultMessageNotifications: this.clampTemplateSetting(template?.default_message_notifications, 0, 1, 0), systemChannelFlags: (template?.system_channel_flags ?? 0) & SUPPORTED_SYSTEM_CHANNEL_FLAGS, diff --git a/fluxer_api/src/api/guild/tests/GuildPhoneVerificationLevel.test.ts b/fluxer_api/src/api/guild/tests/GuildPhoneVerificationLevel.test.ts new file mode 100644 index 000000000..9b03d3ea5 --- /dev/null +++ b/fluxer_api/src/api/guild/tests/GuildPhoneVerificationLevel.test.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {Config} from '@app/api/Config'; +import {createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest'; + +describe('Guild verification level without phone verification', () => { + let harness: ApiTestHarness; + let savedPhoneVerificationEnabled: boolean; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + beforeEach(async () => { + await harness.reset(); + savedPhoneVerificationEnabled = Config.instance.phoneVerificationEnabled; + }); + afterEach(() => { + Config.instance.phoneVerificationEnabled = savedPhoneVerificationEnabled; + }); + + test('rejects very high when phone verification is unavailable', async () => { + Config.instance.phoneVerificationEnabled = false; + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'No Phone Guild'); + await createBuilder(harness, account.token) + .patch(`/guilds/${guild.id}`) + .body({verification_level: GuildVerificationLevel.VERY_HIGH}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + const updated = await updateGuild(harness, account.token, guild.id, { + verification_level: GuildVerificationLevel.HIGH, + }); + expect(updated.verification_level).toBe(GuildVerificationLevel.HIGH); + }); + + test('accepts very high when phone verification is available', async () => { + Config.instance.phoneVerificationEnabled = true; + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Phone Guild'); + const updated = await updateGuild(harness, account.token, guild.id, { + verification_level: GuildVerificationLevel.VERY_HIGH, + }); + expect(updated.verification_level).toBe(GuildVerificationLevel.VERY_HIGH); + }); + + test('accepts an unchanged stored very high when phone verification is unavailable', async () => { + Config.instance.phoneVerificationEnabled = true; + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Stored Phone Guild'); + await updateGuild(harness, account.token, guild.id, {verification_level: GuildVerificationLevel.VERY_HIGH}); + Config.instance.phoneVerificationEnabled = false; + const updated = await updateGuild(harness, account.token, guild.id, { + name: 'Renamed Phone Guild', + verification_level: GuildVerificationLevel.VERY_HIGH, + }); + expect(updated.name).toBe('Renamed Phone Guild'); + expect(updated.verification_level).toBe(GuildVerificationLevel.VERY_HIGH); + }); + + test('clamps a template level to high when phone verification is unavailable', async () => { + Config.instance.phoneVerificationEnabled = false; + const account = await createTestAccount(harness); + const guild = await createBuilder(harness, account.token) + .post('/guilds') + .body({ + name: 'Template Guild', + template: { + name: 'Template Source', + description: null, + verification_level: GuildVerificationLevel.VERY_HIGH, + default_message_notifications: 0, + explicit_content_filter: 0, + system_channel_id: 1001, + afk_timeout: 300, + system_channel_flags: 0, + roles: [{id: 0, name: '@everyone', permissions: '0'}], + channels: [{id: 1001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}], + }, + }) + .execute(); + expect(guild.verification_level).toBe(GuildVerificationLevel.HIGH); + }); +}); diff --git a/fluxer_api/src/api/instance/InstanceController.ts b/fluxer_api/src/api/instance/InstanceController.ts index f060e44c0..1f20215e2 100644 --- a/fluxer_api/src/api/instance/InstanceController.ts +++ b/fluxer_api/src/api/instance/InstanceController.ts @@ -71,6 +71,7 @@ function buildDiscoveryStaticInput( self_hosted: Config.instance.selfHosted, presigned_attachment_uploads: Config.presignedAttachmentUploadsEnabled, emails_enabled: runtime.emailEnabled, + phone_verification_enabled: Config.instance.phoneVerificationEnabled, }, gif: { provider: gifProviderName, diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 97c0ec646..a2f30d7f5 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -30849,6 +30849,10 @@ "emails_enabled": { "type": "boolean", "description": "Whether the instance sends emails (verification, password reset, etc.)" + }, + "phone_verification_enabled": { + "type": "boolean", + "description": "Whether users can verify a phone number, so the very high guild verification level applies" } }, "required": [ @@ -30858,7 +30862,8 @@ "stripe_serviceable", "self_hosted", "presigned_attachment_uploads", - "emails_enabled" + "emails_enabled", + "phone_verification_enabled" ], "additionalProperties": false, "description": "Feature flags for this instance" diff --git a/fluxer_api/src/api/search/guild/GuildSearchSerializer.ts b/fluxer_api/src/api/search/guild/GuildSearchSerializer.ts index fc6898ceb..f4d61c2ec 100644 --- a/fluxer_api/src/api/search/guild/GuildSearchSerializer.ts +++ b/fluxer_api/src/api/search/guild/GuildSearchSerializer.ts @@ -34,7 +34,7 @@ export function convertToSearchableGuild(guild: Guild, discovery?: GuildDiscover bannerHash: guild.bannerHash, splashHash: guild.splashHash, features: Array.from(guild.features), - verificationLevel: getEffectiveGuildVerificationLevel(guild.verificationLevel, isDiscoverable), + verificationLevel: getEffectiveGuildVerificationLevel(guild.verificationLevel, isDiscoverable, true), mfaLevel: guild.mfaLevel, nsfwLevel: guild.nsfwLevel, createdAt, diff --git a/fluxer_api/src/api/user/services/UserAuthRequestService.ts b/fluxer_api/src/api/user/services/UserAuthRequestService.ts index cfdb7e63d..e02941391 100644 --- a/fluxer_api/src/api/user/services/UserAuthRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAuthRequestService.ts @@ -76,11 +76,13 @@ export class UserAuthRequestService { if (user.suspiciousActivityFlags !== 0) { return; } - const guildIds = await this.userRepository.getUserGuildIds(user.id); - if (guildIds.length > 0) { - const guilds = await this.guildRepository.listGuilds(guildIds); - if (guilds.some((g) => g.verificationLevel >= GuildVerificationLevel.VERY_HIGH)) { - return; + if (this.apiContext.services.config.instance.phoneVerificationEnabled) { + const guildIds = await this.userRepository.getUserGuildIds(user.id); + if (guildIds.length > 0) { + const guilds = await this.guildRepository.listGuilds(guildIds); + if (guilds.some((g) => g.verificationLevel >= GuildVerificationLevel.VERY_HIGH)) { + return; + } } } throw new PhoneAddNotEligibleError(); diff --git a/fluxer_api/src/api/utils/GuildVerificationUtils.test.ts b/fluxer_api/src/api/utils/GuildVerificationUtils.test.ts index a47ccaff3..5030bbb94 100644 --- a/fluxer_api/src/api/utils/GuildVerificationUtils.test.ts +++ b/fluxer_api/src/api/utils/GuildVerificationUtils.test.ts @@ -1,12 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createUserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; import {User} from '@app/api/models/User'; import {checkGuildVerificationWithResponse} from '@app/api/utils/GuildVerificationUtils'; import {GuildFeatures, GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants'; import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants'; import {GuildEmailVerificationRequiredError} from '@fluxer/errors/src/domains/auth/EmailVerificationRequiredError'; import {GuildPhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/GuildPhoneVerificationRequiredError'; +import {GuildVerificationRequiredError} from '@fluxer/errors/src/domains/guild/GuildVerificationRequiredError'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import {describe, expect, it} from 'vitest'; @@ -178,4 +180,28 @@ describe('GuildVerificationUtils', () => { }), ).toThrow(GuildPhoneVerificationRequiredError); }); + it('treats very high as high when phone verification is unavailable', () => { + const saved = Config.instance.phoneVerificationEnabled; + Config.instance.phoneVerificationEnabled = false; + try { + const guild = createGuildResponse([]); + guild.verification_level = GuildVerificationLevel.VERY_HIGH; + expect(() => + checkGuildVerificationWithResponse({ + user: createUser({emailVerified: true, hasVerifiedPhone: false}), + guild, + member, + }), + ).not.toThrow(); + expect(() => + checkGuildVerificationWithResponse({ + user: createUser({emailVerified: true, hasVerifiedPhone: false}), + guild, + member: createMemberResponse(new Date().toISOString()), + }), + ).toThrow(GuildVerificationRequiredError); + } finally { + Config.instance.phoneVerificationEnabled = saved; + } + }); }); diff --git a/fluxer_api/src/api/utils/GuildVerificationUtils.ts b/fluxer_api/src/api/utils/GuildVerificationUtils.ts index 99f64333f..ea823a423 100644 --- a/fluxer_api/src/api/utils/GuildVerificationUtils.ts +++ b/fluxer_api/src/api/utils/GuildVerificationUtils.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createRoleIDSet, createUserID, type RoleID, type UserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; import type {Guild} from '@app/api/models/Guild'; import type {GuildMember} from '@app/api/models/GuildMember'; import type {User} from '@app/api/models/User'; @@ -90,6 +91,7 @@ export function checkGuildVerificationWithGuildModel({ verificationLevel: getEffectiveGuildVerificationLevel( guild.verificationLevel ?? GuildVerificationLevel.NONE, guild.features.has(GuildFeatures.DISCOVERABLE), + Config.instance.phoneVerificationEnabled, ), memberJoinedAt: member.joinedAt, memberRoles: member.roleIds, @@ -115,6 +117,7 @@ export function checkGuildVerificationWithResponse({ verificationLevel: getEffectiveGuildVerificationLevel( guild.verification_level ?? GuildVerificationLevel.NONE, (guild.features ?? []).includes(GuildFeatures.DISCOVERABLE), + Config.instance.phoneVerificationEnabled, ), memberJoinedAt: member.joined_at, memberRoles: createRoleIDSet(new Set(member.roles.map((roleId) => BigInt(roleId)))), diff --git a/fluxer_app/src/features/app/state/RuntimeConfig.ts b/fluxer_app/src/features/app/state/RuntimeConfig.ts index 33ec4d90b..3637131ef 100644 --- a/fluxer_app/src/features/app/state/RuntimeConfig.ts +++ b/fluxer_app/src/features/app/state/RuntimeConfig.ts @@ -100,6 +100,7 @@ const DEFAULT_INSTANCE_FEATURES: InstanceFeatures = { self_hosted: false, presigned_attachment_uploads: false, emails_enabled: false, + phone_verification_enabled: true, }; export const DEFAULT_INSTANCE_REGISTRATION: InstanceRegistration = { @@ -516,6 +517,10 @@ class RuntimeConfig { return this.features.emails_enabled; } + get phoneVerificationEnabled(): boolean { + return this.features.phone_verification_enabled; + } + get productName(): string { return this.appPublic.branding.product_name.trim() || DEFAULT_APP_PUBLIC_CONFIG.branding.product_name; } diff --git a/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx b/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx index 1c57cc4e2..9bcf3bf2d 100644 --- a/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx +++ b/fluxer_app/src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import * as GuildCommands from '@app/features/guild/commands/GuildCommands'; import styles from '@app/features/guild/components/modals/guild_tabs/GuildModerationTab.module.css'; import Guilds from '@app/features/guild/state/Guilds'; @@ -176,6 +177,7 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) => const canManageGuild = Permission.can(Permissions.MANAGE_GUILD, {guildId}); const isGuildOwner = guild?.ownerId === currentUser?.id; const isDiscoverable = guild?.features.has(GuildFeatures.DISCOVERABLE) ?? false; + const phoneVerificationEnabled = RuntimeConfig.phoneVerificationEnabled; const remoteValues: FormInputs = { verification_level: guild?.verificationLevel ?? GuildVerificationLevel.NONE, mfa_level: guild?.mfaLevel ?? GuildMFALevel.NONE, @@ -258,7 +260,7 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) => } return; }; - const verificationLevelOptions: ReadonlyArray> = [ + const allVerificationLevelOptions: ReadonlyArray> = [ { value: GuildVerificationLevel.NONE, name: i18n._(VERIFICATION_LEVEL_NONE_NAME_DESCRIPTOR), @@ -286,6 +288,9 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) => desc: i18n._(VERIFICATION_LEVEL_VERY_HIGH_DESCRIPTION_DESCRIPTOR), }, ]; + const verificationLevelOptions = phoneVerificationEnabled + ? allVerificationLevelOptions + : allVerificationLevelOptions.filter((option) => option.value !== GuildVerificationLevel.VERY_HIGH); const matureContentOptions: ReadonlyArray> = [ {value: 'on', label: i18n._(MATURE_CONTENT_ON_DESCRIPTOR)}, {value: 'off', label: i18n._(MATURE_CONTENT_OFF_DESCRIPTOR)}, @@ -324,7 +329,11 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) => control={form.control} render={({field}) => ( 2 The membership age requirement is skipped when the join timestamp cannot be read, so the level behaves as MEDIUM -3 A verified phone number is the whole requirement at this level +3 A verified phone number is the whole requirement at this level. A deployment whose [instance features](/http-api/instance/#instance-features-object) report `phone_verification_enabled` as false evaluates a stored VERY_HIGH as HIGH. [Modify guild](#modify-guild) then rejects a change to VERY_HIGH with 400 `INVALID_FORM_BODY` and the field code `VALUE_MUST_BE_INTEGER_IN_RANGE`, and a [guild template](#guild-creation-template-object) value is clamped to HIGH The guild owner, a bot, and any member holding at least one role bypass the check at every level. @@ -674,7 +674,7 @@ Every field is optional. An omitted field preserves its current value, and a fie 5 The channel must exist in this guild and be a voice channel, and is otherwise rejected with `AFK_CHANNEL_MUST_BE_IN_GUILD` or `AFK_CHANNEL_MUST_BE_VOICE` -6 A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW` +6 A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`. A change to VERY_HIGH is rejected with the field code `VALUE_MUST_BE_INTEGER_IN_RANGE` when phone verification is unavailable, as [Verification levels](#verification-levels) states 7 Sending the value the guild already holds needs neither ownership nor sudo mode, and an owner without a configured second factor is rejected with the field code `MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS` diff --git a/fluxer_docs/src/content/docs/http-api/instance.mdx b/fluxer_docs/src/content/docs/http-api/instance.mdx index 97b4c70f4..bfd806f38 100644 --- a/fluxer_docs/src/content/docs/http-api/instance.mdx +++ b/fluxer_docs/src/content/docs/http-api/instance.mdx @@ -138,6 +138,7 @@ Deployment-wide switches a client reads before it offers a feature, plus whether | self_hosted | boolean | Whether this deployment identifies itself as self-hosted | | presigned_attachment_uploads | boolean | Whether a client can request presigned attachment upload URLs | | emails_enabled1 | boolean | Whether the deployment sends email | +| phone_verification_enabled5 | boolean | Whether accounts can verify a phone number | 1 The value is true only when email is switched on and the transport is completely configured @@ -147,6 +148,8 @@ Deployment-wide switches a client reads before it offers a feature, plus whether 4 On a hosted deployment, true while billing is switched on and a Stripe secret key is set. A self-hosted deployment reports true while a Stripe secret key is set and `premium_enabled` is true, even after billing is switched off +5 Defaults to true on a hosted deployment and false on a self-hosted one. While it is false, the `VERY_HIGH` [verification level](/http-api/guilds/#verification-levels) is evaluated as `HIGH` + A deployment that reports `emails_enabled` as false sends no verification, password recovery, or IP authorisation message, and the flows that depend on one are unusable there. [Deployment availability](/http-api/deployment-availability/) states which routes a self-hosted deployment does not serve at all. ## GIF provider object diff --git a/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx b/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx index a7f010fd7..8958ae4d9 100644 --- a/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx @@ -17,7 +17,7 @@ Every route here needs a non-bot user session, and each one admits a session wit - The account already holds a verified phone. - A TOTP [authenticator](/http-api/users/#authenticator-types) is enrolled on the account. - The stored suspicious activity bitfield is non-zero. -- The account belongs to at least one guild whose [verification level](/http-api/guilds/#verification-levels) is `VERY_HIGH`. +- The account belongs to at least one guild whose [verification level](/http-api/guilds/#verification-levels) is `VERY_HIGH`, and the [instance features](/http-api/instance/#instance-features-object) report `phone_verification_enabled` as true. An account satisfying none of them is refused with 403 `PHONE_ADD_NOT_ELIGIBLE`. diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 924937a43..a6ac90757 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -994,6 +994,10 @@ Defaults to the inverse of `FLUXER_SELF_HOSTED`. Off by default on a self-hosted With feeds off, Fluxer checks none of this data. The first worker start with feeds off removes the URL feed file and every `malware_bazaar` file-SHA ban that no Admin added. File-SHA bans added through the Admin API stay. Turning feeds back on downloads the URLs within six hours and the hashes within twelve. Compose forwards it from `.env`. +#### `FLUXER_PHONE_VERIFICATION_ENABLED` + +Defaults to the inverse of `FLUXER_SELF_HOSTED`. Phone verification needs an external responder on the `rpc.phone.v1` NATS subjects, and none ships with Fluxer. With it off, the `VERY_HIGH` guild verification level is evaluated as `HIGH`, the guild settings stop offering it, and the API rejects it. Turn it on only when your own responder answers those subjects. Compose forwards it from `.env`. + #### `FLUXER_BREACHED_PASSWORD_CHECK_ENABLED` Defaults to the inverse of `FLUXER_SELF_HOSTED`. Breached password rejection. Sends the first five characters of the password's SHA-1 hash to `api.pwnedpasswords.com`. Off by default on a self-hosted instance. Compose forwards it from `.env`. @@ -1078,7 +1082,7 @@ Default `development`. The runtime mode. `development`, `production`, or `test`. #### `FLUXER_SELF_HOSTED` -Default `false`. The self-host switch. Compose sets `true`. It relaxes the production Postgres SSL requirement, seeds the limit tier, gates registration, donation and discovery controllers, keeps premium billing off until it is set up as [Payments](#payments) describes, and turns blocklist feeds off. +Default `false`. The self-host switch. Compose sets `true`. It relaxes the production Postgres SSL requirement, seeds the limit tier, gates registration, donation and discovery controllers, keeps premium billing off until it is set up as [Payments](#payments) describes, and turns blocklist feeds and phone verification off. `/_metrics` on `api`, `media-proxy`, `gateway`, and `push`, plus the Gateway's `/_health/ready`, `/_health/drain`, and `/_health/undrain`, are gated to loopback peers, so no proxy reaches them. The probes that work from outside are `/api/_health`, `/gateway/_health`, `/media/_health`, and the edge's own `/_health`. diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 512a10051..af37c59d3 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -286,6 +286,7 @@ export interface MasterConfig { }; instance: { self_hosted: boolean; + phone_verification_enabled?: boolean; auto_join_invite_code?: string; visionaries_guild_id?: string; visionaries_guild_visionary_role_id?: string; diff --git a/packages/config/src/__tests__/EnvironmentOverrides.test.ts b/packages/config/src/__tests__/EnvironmentOverrides.test.ts index ae4e4371c..cb19aaaff 100644 --- a/packages/config/src/__tests__/EnvironmentOverrides.test.ts +++ b/packages/config/src/__tests__/EnvironmentOverrides.test.ts @@ -290,6 +290,15 @@ describe('buildNamedFluxerEnvOverrides', () => { }); }); + test('maps the phone verification switch onto instance.phone_verification_enabled', () => { + expect(buildNamedFluxerEnvOverrides({FLUXER_PHONE_VERIFICATION_ENABLED: 'true'})).toEqual({ + instance: {phone_verification_enabled: true}, + }); + expect(buildNamedFluxerEnvOverrides({FLUXER_PHONE_VERIFICATION_ENABLED: 'false'})).toEqual({ + instance: {phone_verification_enabled: false}, + }); + }); + test('rejects a store enabled flag that is not a boolean', () => { expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_ENABLED: 'yes'})).toThrow( 'FLUXER_APP_STORE_ENABLED must be true or false', diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 7c4feba4f..ffee9befd 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -265,6 +265,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { parse: parseBoolean, }, FLUXER_SELF_HOSTED: {path: ['instance', 'self_hosted'], parse: parseBoolean}, + FLUXER_PHONE_VERIFICATION_ENABLED: {path: ['instance', 'phone_verification_enabled'], parse: parseBoolean}, FLUXER_AUTO_JOIN_INVITE_CODE: {path: ['instance', 'auto_join_invite_code']}, FLUXER_VISIONARIES_GUILD_ID: {path: ['instance', 'visionaries_guild_id']}, FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: {path: ['instance', 'visionaries_guild_visionary_role_id']}, diff --git a/packages/constants/src/GuildConstants.test.ts b/packages/constants/src/GuildConstants.test.ts index 5e3ddd528..fa98a1ac0 100644 --- a/packages/constants/src/GuildConstants.test.ts +++ b/packages/constants/src/GuildConstants.test.ts @@ -3,6 +3,8 @@ import { clampVoiceChannelBitrate, GuildFeatures, + GuildVerificationLevel, + getEffectiveGuildVerificationLevel, getMaxVoiceChannelBitrate, resolveVoiceChannelBitrate, } from '@fluxer/constants/src/GuildConstants'; @@ -59,3 +61,33 @@ describe('resolveVoiceChannelBitrate', () => { expect(resolveVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000); }); }); + +describe('getEffectiveGuildVerificationLevel', () => { + it('returns the stored level when phone verification is available', () => { + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.VERY_HIGH, false, true)).toBe( + GuildVerificationLevel.VERY_HIGH, + ); + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.NONE, false, true)).toBe( + GuildVerificationLevel.NONE, + ); + }); + it('treats very high as high when phone verification is unavailable', () => { + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.VERY_HIGH, false, false)).toBe( + GuildVerificationLevel.HIGH, + ); + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.VERY_HIGH, true, false)).toBe( + GuildVerificationLevel.HIGH, + ); + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.MEDIUM, false, false)).toBe( + GuildVerificationLevel.MEDIUM, + ); + }); + it('raises a discoverable guild to at least low', () => { + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.NONE, true, true)).toBe( + GuildVerificationLevel.LOW, + ); + expect(getEffectiveGuildVerificationLevel(GuildVerificationLevel.NONE, true, false)).toBe( + GuildVerificationLevel.LOW, + ); + }); +}); diff --git a/packages/constants/src/GuildConstants.ts b/packages/constants/src/GuildConstants.ts index d60e1afae..9d8cd41a4 100644 --- a/packages/constants/src/GuildConstants.ts +++ b/packages/constants/src/GuildConstants.ts @@ -20,11 +20,16 @@ export const GuildVerificationLevel = { export type GuildVerificationLevelValue = ValueOf; -export function getEffectiveGuildVerificationLevel(verificationLevel: number, isDiscoverable: boolean): number { +export function getEffectiveGuildVerificationLevel( + verificationLevel: number, + isDiscoverable: boolean, + phoneVerificationEnabled: boolean, +): number { + const level = phoneVerificationEnabled ? verificationLevel : Math.min(verificationLevel, GuildVerificationLevel.HIGH); if (!isDiscoverable) { - return verificationLevel; + return level; } - return Math.max(verificationLevel, GuildVerificationLevel.LOW); + return Math.max(level, GuildVerificationLevel.LOW); } export const GuildMFALevel = { diff --git a/packages/schema/src/domains/instance/InstanceSchemas.ts b/packages/schema/src/domains/instance/InstanceSchemas.ts index a5396ec80..590533c17 100644 --- a/packages/schema/src/domains/instance/InstanceSchemas.ts +++ b/packages/schema/src/domains/instance/InstanceSchemas.ts @@ -113,6 +113,9 @@ export const InstanceFeaturesSchema = z self_hosted: z.boolean().describe('Whether this is a self-hosted instance'), presigned_attachment_uploads: z.boolean().describe('Whether clients can request presigned attachment upload URLs'), emails_enabled: z.boolean().describe('Whether the instance sends emails (verification, password reset, etc.)'), + phone_verification_enabled: z + .boolean() + .describe('Whether users can verify a phone number, so the very high guild verification level applies'), }) .describe('Feature flags for this instance'); export type InstanceFeatures = z.infer;