From 0967bf31368c6fb58ca38eb98668d1ba93d60597 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 11 Oct 2026 13:31:18 +0200 Subject: [PATCH] fix(api): SSO discovery, channel create and deleted bots (#3390) --- .../src/templates/pages/user_detail.rs | 4 +- .../src/api/auth/services/SsoUtils.test.ts | 56 +++++ fluxer_api/src/api/auth/services/SsoUtils.ts | 18 +- .../channel/tests/AnnouncementTestUtils.ts | 29 +-- .../tests/ChannelCreateCategoryScope.test.ts | 216 ++++++++++++++++++ .../api/guild/services/GuildChannelService.ts | 11 +- .../channel/ChannelOperationsService.ts | 10 +- .../src/api/middleware/ServiceMiddleware.ts | 1 - .../ApplicationMessageAuthorAnonymization.ts | 62 ----- .../src/api/oauth/ApplicationService.ts | 13 -- .../tests/OAuth2ApplicationDelete.test.ts | 16 +- fluxer_api/src/api/test/LimitTestUtils.ts | 32 +++ .../api/webhook/tests/ChannelFollow.test.ts | 2 +- .../tasks/ApplicationProcessDeletion.ts | 32 +-- .../tests/ApplicationProcessDeletion.test.ts | 139 +++++++++++ .../content/docs/http-api/guild-channels.mdx | 7 +- .../content/docs/operator/reverse-proxy.mdx | 16 ++ 17 files changed, 508 insertions(+), 156 deletions(-) create mode 100644 fluxer_api/src/api/auth/services/SsoUtils.test.ts create mode 100644 fluxer_api/src/api/channel/tests/ChannelCreateCategoryScope.test.ts delete mode 100644 fluxer_api/src/api/oauth/ApplicationMessageAuthorAnonymization.ts create mode 100644 fluxer_api/src/api/test/LimitTestUtils.ts create mode 100644 fluxer_api/src/api/worker/tests/ApplicationProcessDeletion.test.ts diff --git a/fluxer_admin/src/templates/pages/user_detail.rs b/fluxer_admin/src/templates/pages/user_detail.rs index 83f9990b3..c06a51f59 100644 --- a/fluxer_admin/src/templates/pages/user_detail.rs +++ b/fluxer_admin/src/templates/pages/user_detail.rs @@ -112,9 +112,9 @@ fn render_user_detail( @if let Some(banner) = banner_url { div class="mb-4 space-y-2" { a href=(banner) target="_blank" rel="noreferrer noopener" - class="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" { + class="block max-w-2xl rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2" { img src=(banner) alt={(user.username) "'s banner"} - class="h-32 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover sm:h-48" + class="aspect-[17/6] w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover" loading="lazy"; } @if let Some(hash) = &user.banner { diff --git a/fluxer_api/src/api/auth/services/SsoUtils.test.ts b/fluxer_api/src/api/auth/services/SsoUtils.test.ts new file mode 100644 index 000000000..c6e3149a0 --- /dev/null +++ b/fluxer_api/src/api/auth/services/SsoUtils.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {tryDiscoverOidcProviderMetadata} from '@app/api/auth/services/SsoUtils'; +import {Config} from '@app/api/Config'; +import {resetSsoRequestUrlPolicyForTesting} from '@app/api/instance/SsoConfigValidation'; +import {server} from '@app/api/test/msw/server'; +import {HttpResponse, http} from 'msw'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +const PRIVATE_ISSUER = 'http://10.20.30.40:5556/dex'; + +describe('tryDiscoverOidcProviderMetadata', () => { + const previousAllowPrivateAddresses = Config.auth.ssoAllowPrivateAddresses; + let discoveryRequests = 0; + + beforeEach(() => { + discoveryRequests = 0; + server.use( + http.get(`${PRIVATE_ISSUER}/.well-known/openid-configuration`, () => { + discoveryRequests++; + return HttpResponse.json({ + issuer: PRIVATE_ISSUER, + authorization_endpoint: `${PRIVATE_ISSUER}/auth`, + token_endpoint: `${PRIVATE_ISSUER}/token`, + userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`, + jwks_uri: `${PRIVATE_ISSUER}/keys`, + }); + }), + ); + }); + + afterEach(() => { + Config.auth.ssoAllowPrivateAddresses = previousAllowPrivateAddresses; + resetSsoRequestUrlPolicyForTesting(); + }); + + it('discovers endpoints on a private issuer when private addresses are allowed', async () => { + Config.auth.ssoAllowPrivateAddresses = true; + resetSsoRequestUrlPolicyForTesting(); + await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toEqual({ + issuer: PRIVATE_ISSUER, + authorization_endpoint: `${PRIVATE_ISSUER}/auth`, + token_endpoint: `${PRIVATE_ISSUER}/token`, + userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`, + jwks_uri: `${PRIVATE_ISSUER}/keys`, + }); + expect(discoveryRequests).toBe(1); + }); + + it('refuses a private issuer when private addresses are not allowed', async () => { + Config.auth.ssoAllowPrivateAddresses = false; + resetSsoRequestUrlPolicyForTesting(); + await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toBeNull(); + expect(discoveryRequests).toBe(0); + }); +}); diff --git a/fluxer_api/src/api/auth/services/SsoUtils.ts b/fluxer_api/src/api/auth/services/SsoUtils.ts index 791919d47..4af6d383f 100644 --- a/fluxer_api/src/api/auth/services/SsoUtils.ts +++ b/fluxer_api/src/api/auth/services/SsoUtils.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {getSsoRequestUrlPolicy} from '@app/api/instance/SsoConfigValidation'; import {Logger} from '@app/api/Logger'; import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits'; import * as FetchUtils from '@app/api/utils/FetchUtils'; @@ -68,13 +69,16 @@ function normalizeIssuerForCompare(value: string): string { export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise { try { const url = buildDiscoveryUrl(issuer); - const resp = await FetchUtils.sendRequest({ - url: url.toString(), - method: 'GET', - headers: {Accept: 'application/json'}, - timeout: ms('10 seconds'), - serviceName: 'sso_oidc_discovery', - }); + const resp = await FetchUtils.sendRequest( + { + url: url.toString(), + method: 'GET', + headers: {Accept: 'application/json'}, + timeout: ms('10 seconds'), + serviceName: 'sso_oidc_discovery', + }, + {requestUrlPolicy: getSsoRequestUrlPolicy()}, + ); if (resp.status < 200 || resp.status >= 300) { FetchUtils.discardResponseBody(resp.stream, resp.status); return null; diff --git a/fluxer_api/src/api/channel/tests/AnnouncementTestUtils.ts b/fluxer_api/src/api/channel/tests/AnnouncementTestUtils.ts index 4b3e31106..b1ec16abb 100644 --- a/fluxer_api/src/api/channel/tests/AnnouncementTestUtils.ts +++ b/fluxer_api/src/api/channel/tests/AnnouncementTestUtils.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; import {createWebhookID} from '@app/api/BrandedTypes'; import {resetApiServicesForTesting} from '@app/api/CreateApiContext'; import { @@ -36,11 +36,8 @@ import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies'; import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage'; import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies'; -import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants'; -import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; -import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes'; import type {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas'; import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; @@ -212,30 +209,6 @@ export async function setGuildFeatures( .execute(); } -export async function setLimitOverride( - harness: ApiTestHarness, - limits: Partial>, -): Promise<() => Promise> { - const admin = await setUserACLs(harness, await createTestAccount(harness), [ - AdminACLs.AUTHENTICATE, - AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW, - AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE, - ]); - const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token) - .get('/admin/limit-config') - .execute(); - const writeConfig = async (rules: LimitConfigSnapshot['rules']) => { - await createBuilder(harness, admin.token) - .put('/admin/limit-config') - .body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}}) - .execute(); - }; - await writeConfig([...current.limit_config.rules, {id: 'announcement_test_override', limits}]); - return async () => { - await writeConfig(current.limit_config.rules); - }; -} - const crosspostTaskHandlers: Record = { crosspostMessage, crosspostMessageChunk, diff --git a/fluxer_api/src/api/channel/tests/ChannelCreateCategoryScope.test.ts b/fluxer_api/src/api/channel/tests/ChannelCreateCategoryScope.test.ts new file mode 100644 index 000000000..f839445b0 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ChannelCreateCategoryScope.test.ts @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + addMemberRole, + createChannel, + createPermissionOverwrite, + createRole, + setupTestGuildWithMembers, +} from '@app/api/channel/tests/ChannelTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {setLimitOverride} from '@app/api/test/LimitTestUtils'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +describe('Channel create in a category', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function setupCategoryManager(extraAllow = 0n) { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1); + const member = members[0]; + const category = await createChannel(harness, owner.token, guild.id, 'managed', ChannelTypes.GUILD_CATEGORY); + const otherCategory = await createChannel(harness, owner.token, guild.id, 'other', ChannelTypes.GUILD_CATEGORY); + const role = await createRole(harness, owner.token, guild.id, {name: 'creators'}); + await addMemberRole(harness, owner.token, guild.id, member.userId, role.id); + await createPermissionOverwrite(harness, owner.token, category.id, role.id, { + type: 0, + allow: (Permissions.VIEW_CHANNEL | Permissions.MANAGE_CHANNELS | extraAllow).toString(), + deny: '0', + }); + return {owner, member, guild, role, category, otherCategory}; + } + + it('lets a category overwrite grant MANAGE_CHANNELS for that category only', async () => { + const {member, guild, category, otherCategory} = await setupCategoryManager(); + const created = await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'member-made', type: ChannelTypes.GUILD_TEXT, parent_id: category.id}) + .execute(); + expect(created.parent_id).toBe(category.id); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'root-made', type: ChannelTypes.GUILD_TEXT}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'elsewhere', type: ChannelTypes.GUILD_TEXT, parent_id: otherCategory.id}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + + it('honors a category deny of MANAGE_CHANNELS for a guild-wide manager', async () => { + const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1); + const member = members[0]; + const category = await createChannel(harness, owner.token, guild.id, 'locked', ChannelTypes.GUILD_CATEGORY); + const role = await createRole(harness, owner.token, guild.id, { + name: 'managers', + permissions: Permissions.MANAGE_CHANNELS.toString(), + }); + await addMemberRole(harness, owner.token, guild.id, member.userId, role.id); + await createPermissionOverwrite(harness, owner.token, category.id, role.id, { + type: 0, + allow: '0', + deny: Permissions.MANAGE_CHANNELS.toString(), + }); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'denied', type: ChannelTypes.GUILD_TEXT, parent_id: category.id}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + + it('lets a category overwrite grant MANAGE_ROLES for requested overwrites within its own permissions', async () => { + const {member, guild, role, category} = await setupCategoryManager(Permissions.MANAGE_ROLES); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({ + name: 'with-overwrites', + type: ChannelTypes.GUILD_TEXT, + parent_id: category.id, + permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}], + }) + .execute(); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({ + name: 'escalate', + type: ChannelTypes.GUILD_TEXT, + parent_id: category.id, + permission_overwrites: [{id: role.id, type: 0, allow: Permissions.ADMINISTRATOR.toString(), deny: '0'}], + }) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + + it('rejects requested overwrites without MANAGE_ROLES on the category', async () => { + const {member, guild, role, category} = await setupCategoryManager(); + await createBuilder(harness, member.token) + .post(`/guilds/${guild.id}/channels`) + .body({ + name: 'no-roles', + type: ChannelTypes.GUILD_TEXT, + parent_id: category.id, + permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}], + }) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + + it('applies a configured max_channels_per_category on create', async () => { + const {owner, guild} = await setupTestGuildWithMembers(harness, 0); + const category = await createChannel(harness, owner.token, guild.id, 'full', ChannelTypes.GUILD_CATEGORY); + const restore = await setLimitOverride(harness, {max_channels_per_category: 0}); + try { + await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'overflow', type: ChannelTypes.GUILD_TEXT, parent_id: category.id}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS) + .execute(); + await createChannel(harness, owner.token, guild.id, 'at-root'); + } finally { + await restore(); + } + }); + + it('applies a configured max_guild_channels on create', async () => { + const {owner, guild} = await setupTestGuildWithMembers(harness, 0); + const restore = await setLimitOverride(harness, {max_guild_channels: 0}); + try { + await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'overflow', type: ChannelTypes.GUILD_TEXT}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS) + .execute(); + } finally { + await restore(); + } + }); + + it('lets a raised max_channels_per_category admit a channel past the default', async () => { + const {owner, guild} = await setupTestGuildWithMembers(harness, 0); + const category = await createChannel(harness, owner.token, guild.id, 'busy', ChannelTypes.GUILD_CATEGORY); + vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50); + await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS) + .execute(); + const restore = await setLimitOverride(harness, {max_channels_per_category: 500}); + try { + const created = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id}) + .execute(); + expect(created.parent_id).toBe(category.id); + } finally { + await restore(); + } + }); + + it('lets a raised max_guild_channels admit a channel past the default', async () => { + const {owner, guild} = await setupTestGuildWithMembers(harness, 0); + vi.spyOn(NoopGatewayService.prototype, 'getChannelCount').mockResolvedValue(500); + await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'one-more', type: ChannelTypes.GUILD_TEXT}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS) + .execute(); + const restore = await setLimitOverride(harness, {max_guild_channels: 1000}); + try { + await createChannel(harness, owner.token, guild.id, 'one-more'); + } finally { + await restore(); + } + }); + + it('applies a configured max_channels_per_category when a channel is moved into a category', async () => { + const {owner, guild} = await setupTestGuildWithMembers(harness, 0); + const category = await createChannel(harness, owner.token, guild.id, 'target', ChannelTypes.GUILD_CATEGORY); + const channel = await createChannel(harness, owner.token, guild.id, 'mover'); + const move = () => + createBuilder(harness, owner.token) + .patch(`/guilds/${guild.id}/channels`) + .body([{id: channel.id, parent_id: category.id}]); + const restoreLowered = await setLimitOverride(harness, {max_channels_per_category: 0}); + try { + await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute(); + } finally { + await restoreLowered(); + } + vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50); + await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute(); + const restoreRaised = await setLimitOverride(harness, {max_channels_per_category: 500}); + try { + await move().expect(HTTP_STATUS.NO_CONTENT).execute(); + } finally { + await restoreRaised(); + } + const moved = await createBuilder(harness, owner.token).get(`/channels/${channel.id}`).execute(); + expect(moved.parent_id).toBe(category.id); + }); +}); diff --git a/fluxer_api/src/api/guild/services/GuildChannelService.ts b/fluxer_api/src/api/guild/services/GuildChannelService.ts index a63524922..b8275c71c 100644 --- a/fluxer_api/src/api/guild/services/GuildChannelService.ts +++ b/fluxer_api/src/api/guild/services/GuildChannelService.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import {type ChannelID, createChannelID, type GuildID, type UserID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings'; @@ -112,6 +112,7 @@ export class GuildChannelService { userId: params.userId, guildId: params.guildId, permission: Permissions.MANAGE_CHANNELS, + channelId: params.data.parent_id ? createChannelID(params.data.parent_id) : undefined, }); const response = await this.channelOps.createChannel(params, auditLogReason); const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]); @@ -155,11 +156,17 @@ export class GuildChannelService { await this.channelOps.sanitizeTextChannelNames(params); } - private async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}): Promise { + private async checkPermission(params: { + userId: UserID; + guildId: GuildID; + permission: bigint; + channelId?: ChannelID; + }): Promise { const hasPermission = await this.gatewayService.checkPermission({ guildId: params.guildId, userId: params.userId, permission: params.permission, + channelId: params.channelId, }); if (!hasPermission) throw new MissingPermissionsError(); const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId}); diff --git a/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts b/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts index 1a5b9f528..cfc16640c 100644 --- a/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts +++ b/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts @@ -104,6 +104,7 @@ export class ChannelOperationsService { guildId: params.guildId, userId: params.userId, permission: Permissions.MANAGE_ROLES, + channelId: parentId ?? undefined, }); if (!canManageRoles) throw new MissingPermissionsError(); const basePermissions = await this.gatewayService.getUserPermissions({ @@ -736,6 +737,7 @@ export class ChannelOperationsService { ctx, 'max_channels_per_category', maxChannels, + 'guild', ); if (count >= maxChannels) { throw new MaxCategoryChannelsError(maxChannels); @@ -747,7 +749,13 @@ export class ChannelOperationsService { let maxChannels = MAX_GUILD_CHANNELS; const guild = await this.guildRepository.findUnique(guildId); const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null}); - maxChannels = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_channels', maxChannels); + maxChannels = resolveLimitSafe( + this.limitConfigService.getConfigSnapshot(), + ctx, + 'max_guild_channels', + maxChannels, + 'guild', + ); if (count >= maxChannels) { throw new MaxGuildChannelsError(maxChannels); } diff --git a/fluxer_api/src/api/middleware/ServiceMiddleware.ts b/fluxer_api/src/api/middleware/ServiceMiddleware.ts index 643fcf8cf..a64db0141 100644 --- a/fluxer_api/src/api/middleware/ServiceMiddleware.ts +++ b/fluxer_api/src/api/middleware/ServiceMiddleware.ts @@ -527,7 +527,6 @@ class RequestServices implements RequestScopedServices { get applicationService(): ApplicationService { this.cachedApplicationService ??= new ApplicationService(this.context, { applicationRepository: getApplicationRepository(), - channelRepository: getChannelRepository(), userCacheService: getUserCacheService(), entityAssetService: getEntityAssetService(), discriminatorService: getDiscriminatorService(), diff --git a/fluxer_api/src/api/oauth/ApplicationMessageAuthorAnonymization.ts b/fluxer_api/src/api/oauth/ApplicationMessageAuthorAnonymization.ts deleted file mode 100644 index 3266a7d84..000000000 --- a/fluxer_api/src/api/oauth/ApplicationMessageAuthorAnonymization.ts +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {createUserID, type UserID} from '@app/api/BrandedTypes'; -import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; -import {MessageAnonymizationService} from '@app/api/channel/services/message/MessageAnonymizationService'; -import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes'; -import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; -import {Logger} from '@app/api/Logger'; -import type {UserRepository} from '@app/api/user/repositories/UserRepository'; -import { - DELETED_USER_DISCRIMINATOR, - DELETED_USER_GLOBAL_NAME, - DELETED_USER_USERNAME, - UserFlags, -} from '@fluxer/constants/src/UserConstants'; - -interface RemapAuthorMessagesToDeletedUserParams { - originalAuthorId: UserID; - channelRepository: ChannelRepository; - userRepository: UserRepository; - snowflakeService: ISnowflakeService; -} - -async function createDeletedMessageAuthorUser(params: { - userRepository: UserRepository; - snowflakeService: ISnowflakeService; -}): Promise { - const deletedUserId = createUserID(await params.snowflakeService.generate()); - await params.userRepository.create({ - ...EMPTY_USER_ROW, - user_id: deletedUserId, - username: DELETED_USER_USERNAME, - discriminator: DELETED_USER_DISCRIMINATOR, - global_name: DELETED_USER_GLOBAL_NAME, - bot: false, - system: false, - flags: UserFlags.DELETED, - }); - await params.userRepository.deleteUserSecondaryIndices(deletedUserId); - return deletedUserId; -} - -export async function remapAuthorMessagesToDeletedUser( - params: RemapAuthorMessagesToDeletedUserParams, -): Promise { - const {originalAuthorId, channelRepository, userRepository, snowflakeService} = params; - const hasMessages = await channelRepository.messages.listMessagesByAuthor(originalAuthorId, 1); - if (hasMessages.length === 0) { - return null; - } - const replacementAuthorId = await createDeletedMessageAuthorUser({ - userRepository, - snowflakeService, - }); - const anonymizationService = new MessageAnonymizationService(channelRepository); - await anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, replacementAuthorId); - Logger.info( - {originalAuthorId: originalAuthorId.toString(), replacementAuthorId: replacementAuthorId.toString()}, - 'Remapped authored messages to deleted user id', - ); - return replacementAuthorId; -} diff --git a/fluxer_api/src/api/oauth/ApplicationService.ts b/fluxer_api/src/api/oauth/ApplicationService.ts index 5f64e9ec5..255b71da2 100644 --- a/fluxer_api/src/api/oauth/ApplicationService.ts +++ b/fluxer_api/src/api/oauth/ApplicationService.ts @@ -3,7 +3,6 @@ import type {ApiContext} from '@app/api/ApiContext'; import type {ApplicationID, UserID} from '@app/api/BrandedTypes'; import {applicationIdToUserId} from '@app/api/BrandedTypes'; -import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; import type {ApplicationRow} from '@app/api/database/types/OAuth2Types'; import type {UserRow} from '@app/api/database/types/UserTypes'; import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; @@ -14,7 +13,6 @@ import {Logger} from '@app/api/Logger'; import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache'; import type {Application} from '@app/api/models/Application'; import type {User} from '@app/api/models/User'; -import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization'; import type {BotAuthService} from '@app/api/oauth/BotAuthService'; import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret'; import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository'; @@ -43,7 +41,6 @@ import type {BotProfileUpdateRequest} from '@fluxer/schema/src/domains/oauth/OAu interface ApplicationServiceDeps { discriminatorService: DiscriminatorService; - channelRepository: ChannelRepository; applicationRepository: ApplicationRepository; botAuthService: BotAuthService; entityAssetService: EntityAssetService; @@ -307,12 +304,6 @@ export class ApplicationService { const application = await this.verifyOwnership(userId, applicationId); if (application.hasBotUser()) { const botUserId = application.getBotUserId()!; - const replacementAuthorId = await remapAuthorMessagesToDeletedUser({ - originalAuthorId: botUserId, - channelRepository: this.deps.channelRepository, - userRepository: this.apiContext.services.users, - snowflakeService: this.apiContext.services.snowflake, - }); const guildIds = await this.apiContext.services.users.getUserGuildIds(botUserId); await this.apiContext.services.users.deleteUserSecondaryIndices(botUserId); await this.apiContext.services.users.removeFromAllGuilds(botUserId); @@ -372,14 +363,10 @@ export class ApplicationService { botUser.toRow(), ); await this.deps.userCacheService.invalidateUserCache(botUserId); - if (replacementAuthorId) { - await this.deps.userCacheService.invalidateUserCache(replacementAuthorId); - } Logger.info( { applicationId: applicationId.toString(), botUserId: botUserId.toString(), - replacementAuthorId: replacementAuthorId?.toString() ?? null, }, 'Anonymized bot user associated with application', ); diff --git a/fluxer_api/src/api/oauth/tests/OAuth2ApplicationDelete.test.ts b/fluxer_api/src/api/oauth/tests/OAuth2ApplicationDelete.test.ts index e9af306f5..eef3302b3 100644 --- a/fluxer_api/src/api/oauth/tests/OAuth2ApplicationDelete.test.ts +++ b/fluxer_api/src/api/oauth/tests/OAuth2ApplicationDelete.test.ts @@ -94,25 +94,17 @@ describe('OAuth2 Application Delete', () => { .execute(); const foundMessage = messages.find((message) => message.id === botMessageId); expect(foundMessage).toBeDefined(); - expect(foundMessage?.author.id).not.toBe(createResult.botUserId); + expect(foundMessage?.author.id).toBe(createResult.botUserId); + expect(foundMessage?.author.bot).toBe(true); expect(foundMessage?.author.username).toBe('DeletedUser'); expect(foundMessage?.author.discriminator).toBe('0000'); - const previousAuthorCount = await createBuilderWithoutAuth<{ + const authorCount = await createBuilderWithoutAuth<{ count: number; }>(harness) .get(`/test/users/${createResult.botUserId}/messages/count`) .expect(HTTP_STATUS.OK) .execute(); - expect(previousAuthorCount.count).toBe(0); - const replacementAuthorId = foundMessage?.author.id; - expect(replacementAuthorId).toBeTruthy(); - const replacementAuthorCount = await createBuilderWithoutAuth<{ - count: number; - }>(harness) - .get(`/test/users/${replacementAuthorId}/messages/count`) - .expect(HTTP_STATUS.OK) - .execute(); - expect(replacementAuthorCount.count).toBe(1); + expect(authorCount.count).toBe(1); }); test('enforces access control', async () => { const owner = await createTestAccount(harness); diff --git a/fluxer_api/src/api/test/LimitTestUtils.ts b/fluxer_api/src/api/test/LimitTestUtils.ts new file mode 100644 index 000000000..b310e4a66 --- /dev/null +++ b/fluxer_api/src/api/test/LimitTestUtils.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils'; +import type {ApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; +import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes'; + +export async function setLimitOverride( + harness: ApiTestHarness, + limits: Partial>, +): Promise<() => Promise> { + const admin = await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW, + AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE, + ]); + const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token) + .get('/admin/limit-config') + .execute(); + const writeConfig = async (rules: LimitConfigSnapshot['rules']) => { + await createBuilder(harness, admin.token) + .put('/admin/limit-config') + .body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}}) + .execute(); + }; + await writeConfig([...current.limit_config.rules, {id: 'test_limit_override', limits}]); + return async () => { + await writeConfig(current.limit_config.rules); + }; +} diff --git a/fluxer_api/src/api/webhook/tests/ChannelFollow.test.ts b/fluxer_api/src/api/webhook/tests/ChannelFollow.test.ts index 05031b904..30841a071 100644 --- a/fluxer_api/src/api/webhook/tests/ChannelFollow.test.ts +++ b/fluxer_api/src/api/webhook/tests/ChannelFollow.test.ts @@ -11,13 +11,13 @@ import { follow, followRequest, setGuildFeatures, - setLimitOverride, } from '@app/api/channel/tests/AnnouncementTestUtils'; import {createGuild, createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils'; import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils'; import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {setLimitOverride} from '@app/api/test/LimitTestUtils'; import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; import {HTTP_STATUS} from '@app/api/test/TestConstants'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; diff --git a/fluxer_api/src/api/worker/tasks/ApplicationProcessDeletion.ts b/fluxer_api/src/api/worker/tasks/ApplicationProcessDeletion.ts index 560aa6bbc..a3d52e64f 100644 --- a/fluxer_api/src/api/worker/tasks/ApplicationProcessDeletion.ts +++ b/fluxer_api/src/api/worker/tasks/ApplicationProcessDeletion.ts @@ -4,7 +4,6 @@ import {applicationIdToUserId, createApplicationID, type GuildID} from '@app/api import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel'; import {Logger} from '@app/api/Logger'; import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; -import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization'; import {chunkArray} from '@app/api/utils/ArrayUtils'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; import { @@ -25,15 +24,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) = helpers.logger.debug({payload: validated}, 'Processing applicationProcessDeletion task'); const applicationId = createApplicationID(BigInt(validated.applicationId)); const botUserId = applicationIdToUserId(applicationId); - const { - userRepository, - guildRepository, - channelRepository, - applicationRepository, - userCacheService, - gatewayService, - snowflakeService, - } = getWorkerDependencies(); + const {userRepository, guildRepository, applicationRepository, userCacheService, gatewayService} = + getWorkerDependencies(); Logger.debug({applicationId, botUserId}, 'Starting application deletion'); try { const application = await applicationRepository.getApplication(applicationId); @@ -42,21 +34,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) = return; } const botUser = await userRepository.findUniqueAssert(botUserId); - const replacementAuthorId = await remapAuthorMessagesToDeletedUser({ - originalAuthorId: botUserId, - channelRepository, - userRepository, - snowflakeService, - }); if (botUser.flags & UserFlags.DELETED) { - Logger.info( - { - applicationId, - botUserId, - replacementAuthorId: replacementAuthorId?.toString() ?? null, - }, - 'Bot user already marked as deleted, skipping profile update', - ); + Logger.info({applicationId, botUserId}, 'Bot user already marked as deleted, skipping profile update'); await applicationRepository.deleteApplication(applicationId); return; } @@ -66,6 +45,11 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) = username: DELETED_USER_USERNAME, global_name: DELETED_USER_GLOBAL_NAME, discriminator: DELETED_USER_DISCRIMINATOR, + avatar_hash: null, + banner_hash: null, + bio: null, + pronouns: null, + accent_color: null, }, botUser.toRow(), ); diff --git a/fluxer_api/src/api/worker/tests/ApplicationProcessDeletion.test.ts b/fluxer_api/src/api/worker/tests/ApplicationProcessDeletion.test.ts new file mode 100644 index 000000000..5c24361cf --- /dev/null +++ b/fluxer_api/src/api/worker/tests/ApplicationProcessDeletion.test.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createApplicationID, createUserID} from '@app/api/BrandedTypes'; +import {getGatewayService} from '@app/api/middleware/ServiceRegistry'; +import { + createUserCacheService, + getApplicationRepository, + getGuildRepository, + getUserRepository, +} from '@app/api/middleware/ServiceSingletons'; +import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import applicationProcessDeletion from '@app/api/worker/tasks/ApplicationProcessDeletion'; +import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {UserFlags} from '@fluxer/constants/src/UserConstants'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest'; + +function createHelpers(): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 7200000000000000000n, + addJob: async () => 0n, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + }; +} + +describe('applicationProcessDeletion task', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + afterAll(async () => { + await harness.shutdown(); + }); + beforeEach(async () => { + await harness.resetData(); + setWorkerDependenciesForTest({ + userRepository: getUserRepository(), + guildRepository: getGuildRepository(), + applicationRepository: getApplicationRepository(), + userCacheService: createUserCacheService(), + gatewayService: getGatewayService(), + }); + }); + afterEach(() => { + clearWorkerDependencies(); + }); + + test('keeps the bot as author of its messages and clears its profile', async () => { + const account = await createTestAccount(harness); + const createResult = await createOAuth2Application(harness, account.token, { + name: createUniqueApplicationName(), + }); + const botUserId = createUserID(BigInt(createResult.botUserId)); + const userRepository = getUserRepository(); + const botUser = await userRepository.findUniqueAssert(botUserId); + await userRepository.patchUpsert( + botUserId, + { + avatar_hash: 'a'.repeat(32), + banner_hash: 'b'.repeat(32), + bio: 'bot bio', + pronouns: 'it/its', + accent_color: 0x4641d9, + }, + botUser.toRow(), + ); + const guild = await createBuilder<{ + id: string; + system_channel_id?: string; + }>(harness, account.token) + .post('/guilds') + .body({name: `Bot Message Retention ${Date.now()}`}) + .expect(HTTP_STATUS.OK) + .execute(); + const channelId = guild.system_channel_id; + if (!channelId) { + throw new Error('Guild response missing system channel'); + } + const seeded = await createBuilder<{ + messages: Array<{ + message_id: string; + }>; + }>(harness, '') + .post('/test/messages/seed') + .body({ + channel_id: channelId, + author_id: createResult.botUserId, + clear_existing: true, + messages: [{content: 'bot message before deletion'}], + }) + .expect(HTTP_STATUS.OK) + .execute(); + const botMessageId = seeded.messages[0]?.message_id; + if (!botMessageId) { + throw new Error('Seeded message response missing message id'); + } + + await applicationProcessDeletion({applicationId: createResult.application.id}, createHelpers()); + + expect( + await getApplicationRepository().getApplication(createApplicationID(BigInt(createResult.application.id))), + ).toBeNull(); + const deletedBot = await userRepository.findUniqueAssert(botUserId); + expect(deletedBot.isBot).toBe(true); + expect(deletedBot.flags & UserFlags.DELETED).toBe(UserFlags.DELETED); + expect(deletedBot.username).toBe('DeletedUser'); + expect(deletedBot.avatarHash).toBeNull(); + expect(deletedBot.bannerHash).toBeNull(); + expect(deletedBot.bio).toBeNull(); + expect(deletedBot.pronouns).toBeNull(); + expect(deletedBot.accentColor).toBeNull(); + const messages = await createBuilder>(harness, account.token) + .get(`/channels/${channelId}/messages`) + .expect(HTTP_STATUS.OK) + .execute(); + const foundMessage = messages.find((message) => message.id === botMessageId); + expect(foundMessage?.author.id).toBe(createResult.botUserId); + expect(foundMessage?.author.bot).toBe(true); + expect(foundMessage?.author.username).toBe('DeletedUser'); + expect(foundMessage?.author.discriminator).toBe('0000'); + expect(foundMessage?.author.avatar).toBeNull(); + const authorCount = await createBuilderWithoutAuth<{ + count: number; + }>(harness) + .get(`/test/users/${createResult.botUserId}/messages/count`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(authorCount.count).toBe(1); + }); +}); diff --git a/fluxer_docs/src/content/docs/http-api/guild-channels.mdx b/fluxer_docs/src/content/docs/http-api/guild-channels.mdx index 1d537b21e..a8197310b 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-channels.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-channels.mdx @@ -137,11 +137,12 @@ A guild holds at most the instance-configured `max_guild_channels` [limit](/http -Creates a guild channel and returns its [channel object](/http-api/channels/#channel-object) with status 200. Requires [MANAGE_CHANNELS](/http-api/permissions/) at guild level. Emits a [Channel Create](/gateway/events/#channel-create) Gateway event. +Creates a guild channel and returns its [channel object](/http-api/channels/#channel-object) with status 200. Requires [MANAGE_CHANNELS](/http-api/permissions/) in the parent category when `parent_id` is supplied, and at guild level otherwise. Emits a [Channel Create](/gateway/events/#channel-create) Gateway event. ### Limitations -- Supplying `permission_overwrites` also requires [MANAGE_ROLES](/http-api/permissions/) at guild level. +- Supplying `permission_overwrites` also requires [MANAGE_ROLES](/http-api/permissions/) in the same context. +- Both permissions resolve through the parent category's overwrites. A category overwrite that allows one grants it for that category only, and a category overwrite that denies one blocks a caller who holds it at guild level. `MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions). It also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner. @@ -216,7 +217,7 @@ Only the allowed mask is compared, so a supplied deny is stored without any auth | --- | --- | --- | | 200 | [channel](/http-api/channels/#channel-object) object | Channel was created | | 40012 | [error response](/http-api/#error-response) | Body, type, name, parent, URL, voice setting, or content warning text is invalid, a channel limit is reached, or the caller has no enrolled authenticator in an elevated-MFA guild | -| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_CHANNELS`, is not a member of the guild, supplied overwrites without `MANAGE_ROLES`, or allowed a permission they do not hold in the parent context, each returning `MISSING_PERMISSIONS` | +| 403 | [error response](/http-api/#error-response) | Caller is not a member of the guild, lacks `MANAGE_CHANNELS` in the parent category or in the guild when no parent is supplied, supplied overwrites without `MANAGE_ROLES` in that context, or allowed a permission they do not hold there, each returning `MISSING_PERMISSIONS` | | 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` | 1 A schema or parent failure returns `INVALID_FORM_BODY` and names the field with `CATEGORIES_CANNOT_HAVE_PARENTS`, `INVALID_PARENT_CHANNEL`, or `PARENT_MUST_BE_CATEGORY`. A capacity failure returns the top-level `MAX_GUILD_CHANNELS` or `MAX_CATEGORY_CHANNELS` diff --git a/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx b/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx index 6f08f1dad..13a49fe48 100644 --- a/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx +++ b/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx @@ -58,6 +58,22 @@ Forward all paths unchanged to the edge. No internal routing changes are needed. With `FLUXER_PUBLIC_SCHEME=https` the admin CSRF cookie is `__Host-csrf_token`, which browsers accept only over HTTPS. +#### Keep TLS 1.2 enabled + +The Android and iOS apps play video through a media library whose TLS stack offers only TLS 1.2. The rest of each app uses the system TLS stack. Behind a proxy that accepts only TLS 1.3 the apps sign in and load messages and images, but every video fails with "Could not play this video." + +Accept TLS 1.2 alongside TLS 1.3. TLS 1.2 can stay limited to ECDHE key exchange with AES-GCM or ChaCha20-Poly1305, which the apps support. + +| Proxy | Setting | +| --- | --- | +| nginx | `ssl_protocols TLSv1.2 TLSv1.3;` | +| Caddy | `protocols tls1.2 tls1.3` | +| Traefik | `minVersion: VersionTLS12` | +| HAProxy | `ssl-min-ver TLSv1.2` | +| Apache httpd | `SSLProtocol -all +TLSv1.2 +TLSv1.3` | + +Caddy and Traefik accept both versions by default, so their rows apply only where a `tls` block or a TLS option already sets a floor. The HAProxy keyword goes on the `bind` line. On Cloudflare, keep Minimum TLS Version at 1.2 or lower. The bundled edge accepts both versions. + #### Forward WebSocket upgrades The client connection is on `/gateway` and voice signalling is on `/livekit/*`. Both open as WebSocket upgrades. Check that the handshake completes through whatever is in front of the instance, with your own hostname in place of `chat.example.com`: