fix(guild): treat very high as high without phone verification (#3095)

This commit is contained in:
Hampus
2026-10-01 20:33:02 +02:00
committed by GitHub
parent d691047884
commit 2df82b2b5e
28 changed files with 289 additions and 18 deletions
+3
View File
@@ -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=
+1
View File
@@ -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:-}
+38
View File
@@ -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);
});
});
+1
View File
@@ -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,
+1
View File
@@ -262,6 +262,7 @@ export interface APIConfig {
};
instance: {
selfHosted: boolean;
phoneVerificationEnabled: boolean;
autoJoinInviteCode?: string;
visionariesGuildId?: string;
visionariesGuildVisionaryRoleId?: string;
@@ -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) {
@@ -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,
@@ -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<GuildResponse>(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);
});
});
@@ -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,
+6 -1
View File
@@ -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"
@@ -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,
@@ -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();
@@ -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;
}
});
});
@@ -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)))),
@@ -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;
}
@@ -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<RadioOption<number>> = [
const allVerificationLevelOptions: ReadonlyArray<RadioOption<number>> = [
{
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<ComboboxOption<string>> = [
{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}) => (
<RadioGroup
value={getEffectiveGuildVerificationLevel(field.value ?? GuildVerificationLevel.NONE, isDiscoverable)}
value={getEffectiveGuildVerificationLevel(
field.value ?? GuildVerificationLevel.NONE,
isDiscoverable,
phoneVerificationEnabled,
)}
onChange={field.onChange}
disabled={!canManageGuild}
options={verificationLevelOptions}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import GuildMembers from '@app/features/member/state/GuildMembers';
@@ -143,6 +144,7 @@ class GuildVerification {
const verificationLevel = getEffectiveGuildVerificationLevel(
guild.verificationLevel ?? GuildVerificationLevel.NONE,
guild.features.has(GuildFeatures.DISCOVERABLE),
RuntimeConfig.phoneVerificationEnabled,
);
if (verificationLevel === GuildVerificationLevel.NONE) {
return {canAccess: true};
@@ -320,6 +320,7 @@ const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
self_hosted: false,
presigned_attachment_uploads: false,
emails_enabled: false,
phone_verification_enabled: true,
},
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''},
@@ -290,7 +290,7 @@ A verification level gates member participation. Fluxer evaluates it when a memb
<sup>2</sup> The membership age requirement is skipped when the join timestamp cannot be read, so the level behaves as MEDIUM
<sup>3</sup> A verified phone number is the whole requirement at this level
<sup>3</sup> 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
<sup>5</sup> 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`
<sup>6</sup> A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`
<sup>6</sup> 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
<sup>7</sup> 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`
@@ -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_enabled<sup>1</sup> | boolean | Whether the deployment sends email |
| phone_verification_enabled<sup>5</sup> | boolean | Whether accounts can verify a phone number |
<sup>1</sup> 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
<sup>4</sup> 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
<sup>5</sup> 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
@@ -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`.
@@ -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`.
+1
View File
@@ -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;
@@ -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',
@@ -265,6 +265,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
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']},
@@ -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,
);
});
});
+8 -3
View File
@@ -20,11 +20,16 @@ export const GuildVerificationLevel = {
export type GuildVerificationLevelValue = ValueOf<typeof GuildVerificationLevel>;
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 = {
@@ -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<typeof InstanceFeaturesSchema>;