feat(threads): ship threads and the Plutonium page to everyone (#3277)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 16:30:39 +02:00
1 parent 1515a8487f
commit f8505d19f7
53 files changed
+296 -2008

No files matched your search

@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes';
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {
clearChannelThreadsTaintCacheForTesting,
isTainted,
@@ -16,7 +14,6 @@ import {
resolveThreadPermissionMode,
shouldMaskThreadPermissionBits,
} from '@app/api/guild/services/ThreadPermissionBits';
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {
applyProtectedOverwriteBits,
@@ -26,14 +23,7 @@ import {
protectedThreadBits,
} from '@app/api/utils/featureUtils';
import {computePermissionsDiff} from '@app/api/utils/PermissionUtils';
import {
enabledThreadGuildIds,
enqueueThreadPermissionSeeds,
newlyEnabledThreadGuildIds,
seedThreadOverwriteBits,
seedThreadOverwriteValue,
} from '@app/api/worker/tasks/SeedThreadPermissions';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {seedThreadOverwriteBits, seedThreadOverwriteValue} from '@app/api/worker/tasks/SeedThreadPermissions';
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {
@@ -43,13 +33,9 @@ import {
ThreadPermissionFlags,
} from '@fluxer/constants/src/ThreadPermissionUtils';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {
ChannelThreadsConfigSchema,
DEFAULT_CHANNEL_THREADS_CONFIG,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {afterEach, describe, expect, it} from 'vitest';
const NONE: ReadonlySet<string> = new Set();
const CAPABLE: ReadonlySet<string> = new Set(['channel_threads']);
@@ -254,61 +240,4 @@ describe('thread permission seeding rules', () => {
Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS,
);
});
it('targets enabled, non-disabled guilds of an enabled config', () => {
const config = {
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: true,
enabled_guild_ids: ['1', '2', '3'],
disabled_guild_ids: ['3'],
};
expect(enabledThreadGuildIds(config)).toEqual(['1', '2']);
expect(enabledThreadGuildIds({...config, enabled: false})).toEqual([]);
});
it('lists only guilds that became active in the landed config', () => {
const previous = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
expect(newlyEnabledThreadGuildIds(previous, previous)).toEqual([]);
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled_guild_ids: ['1', '2', '3', '4']})).toEqual(['4']);
expect(newlyEnabledThreadGuildIds({...previous, disabled_guild_ids: ['2']}, previous)).toEqual(['2']);
expect(newlyEnabledThreadGuildIds({...previous, enabled: false}, previous)).toEqual(['1', '2', '3']);
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled: false})).toEqual([]);
});
it('enqueues seeds for every targeted guild without a seeded marker, so a retried update still seeds', async () => {
const addJob = vi.fn(async () => {});
setInjectedWorkerService({addJob} as unknown as IWorkerService<WorkerTaskName>);
const markers = new Map<string, GuildThreadStateRow>([
[
'1',
{
guild_id: createGuildID(1n),
first_active_at: new Date(),
perms_seeded_at: new Date(),
search_backfilled_at: null,
},
],
[
'2',
{guild_id: createGuildID(2n), first_active_at: new Date(), perms_seeded_at: null, search_backfilled_at: null},
],
]);
const threads = {
getGuildMarker: async (guildId: GuildID) => markers.get(guildId.toString()) ?? null,
} as unknown as IThreadRepository;
const config = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
try {
await enqueueThreadPermissionSeeds(threads, config);
await enqueueThreadPermissionSeeds(threads, config);
} finally {
setInjectedWorkerService(undefined);
}
const keys = addJob.mock.calls.map((call) => (call as unknown as [string, unknown, {jobKey: string}])[2].jobKey);
expect(keys).toEqual([
'seed-thread-permissions-2',
'seed-thread-permissions-3',
'seed-thread-permissions-2',
'seed-thread-permissions-3',
]);
});
});
@@ -4,12 +4,9 @@ import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannelID, createGuildID, createRoleID} from '@app/api/BrandedTypes';
import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {fetchOne} from '@app/api/database/CassandraQueryExecution';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
import {
acceptInvite,
@@ -20,7 +17,6 @@ import {
getChannel,
updateRolePositions,
} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {GuildThreadState} from '@app/api/Tables';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
@@ -31,10 +27,7 @@ import {
THREAD_PERMISSIONS,
ThreadPermissionFlags,
} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
@@ -50,17 +43,6 @@ const ACTIVE: Partial<ChannelThreadsConfig> = {
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
config_version: current.config_version + 1,
}),
);
clearChannelThreadsTaintCacheForTesting();
}
function bits(value: string | undefined): bigint {
return BigInt(value ?? '0');
}
@@ -74,13 +56,11 @@ describe('thread permission bits across guild modes', () => {
beforeEach(async () => {
await harness.reset();
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
resetChannelThreadsConfig();
});
afterEach(() => {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -124,7 +104,7 @@ describe('thread permission bits across guild modes', () => {
});
test('active guilds seed @everyone, write bits for capable users and restore them for others', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Active');
expect(await roleBits(owner.token, guild.id, guild.id)).toBe(DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS);
@@ -155,7 +135,7 @@ describe('thread permission bits across guild modes', () => {
});
test('bots write thread bits and bot invites keep them only for capable active users in active guilds', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bots');
const bot = await createTestBotAccount(harness);
@@ -185,7 +165,7 @@ describe('thread permission bits across guild modes', () => {
.execute();
expect(bits(vcm.permissions)).toBe(0n);
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
const controlOwner = await createTestAccount(harness);
const controlGuild = await createGuild(harness, controlOwner.token, 'Bots control');
const controlBot = await createTestBotAccount(harness);
@@ -197,7 +177,7 @@ describe('thread permission bits across guild modes', () => {
});
test('retired guilds keep stored thread bits read-only without escalation errors', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Retired');
@@ -222,7 +202,7 @@ describe('thread permission bits across guild modes', () => {
await acceptInvite(harness, member.token, invite.code);
await addMemberRole(harness, owner.token, guild.id, member.userId, mod.id);
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
const updated = await createBuilder<GuildRoleResponse>(harness, member.token)
.patch(`/guilds/${guild.id}/roles/${target.id}`)
.header(FEATURES, CAPABLE)
@@ -268,7 +248,7 @@ describe('thread permission bits across guild modes', () => {
});
test('active channel creates store thread-aware overwrites and mask them for non-viewers', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Overwrites');
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -309,7 +289,7 @@ describe('thread permission bits across guild modes', () => {
});
test('overwrite removals keep thread bits the actor cannot write, without escalation errors', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Removals');
@@ -352,7 +332,7 @@ describe('thread permission bits across guild modes', () => {
expect([after.everyone?.allow, after.everyone?.deny]).toEqual([inThreads, 0n]);
expect([after.target?.allow, after.target?.deny]).toEqual([0n, manageThreads]);
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
await createBuilder(harness, member.token)
.patch(`/channels/${channel.id}`)
.header(FEATURES, CAPABLE)
@@ -368,7 +348,7 @@ describe('thread permission bits across guild modes', () => {
});
test('deleting a forum removes its thread-only index row', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Forum delete');
const guildId = createGuildID(BigInt(guild.id));
@@ -390,7 +370,7 @@ describe('thread permission bits across guild modes', () => {
expect(await repository.channelData.countGuildChannels(guildId)).toBe(before);
});
test('category sync and delete in a retired guild update forums without dispatching them', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Retired category');
const guildId = createGuildID(BigInt(guild.id));
@@ -407,7 +387,7 @@ describe('thread permission bits across guild modes', () => {
const forumId = createChannelID(BigInt(text.id) + 1n);
await repository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'forum'});
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).not.toBeNull();
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
const updatedIds = () =>
dispatchSpy.mock.calls
@@ -431,7 +411,7 @@ describe('thread permission bits across guild modes', () => {
}
});
test('channel overwrite edits keep thread bits for clients that cannot write them', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Overwrite edits');
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -474,7 +454,7 @@ describe('thread permission bits across guild modes', () => {
expect(await storedAllow()).toBeUndefined();
});
test('lock_permissions moves copy parent thread bits only for writers', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Lock sync');
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -545,7 +525,7 @@ describe('thread permission bits across guild modes', () => {
const controlRoles = await listRoles(controlOwner.token, controlGuild.id, true);
expect(controlRoles.map((role) => bits(role.permissions) & THREAD_PERMISSIONS)).toEqual([0n, 0n]);
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await templated(owner.token);
const roles = await listRoles(owner.token, guild.id, true);
@@ -1,42 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {acceptInvite, createChannelInvite, createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
const FEATURES = 'X-Fluxer-Features';
const CAPABLE = 'channel_threads';
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
config_version: current.config_version + 1,
}),
);
clearChannelThreadsTaintCacheForTesting();
}
function resetGate(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('threads_active on GET /users/@me/guilds', () => {
let harness: ApiTestHarness;
@@ -46,12 +23,12 @@ describe('threads_active on GET /users/@me/guilds', () => {
beforeEach(async () => {
await harness.reset();
resetGate();
resetChannelThreadsConfig();
});
afterEach(() => {
vi.restoreAllMocks();
resetGate();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -93,7 +70,7 @@ describe('threads_active on GET /users/@me/guilds', () => {
test('flags only the enrolled guild for an enrolled capable viewer', async () => {
const {owner, enrolled, plain, ownerBaseline} = await setup();
await setConfig(enrol(enrolled, [owner.userId]));
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
const guilds = await listGuilds(owner.token, true);
const byId = new Map(guilds.map((guild) => [guild.id, guild]));
const baseline = new Map(ownerBaseline.map((guild) => [guild.id, guild]));
@@ -109,17 +86,17 @@ describe('threads_active on GET /users/@me/guilds', () => {
for (const guild of guilds) expect(guild).not.toHaveProperty('threads_active');
};
await setConfig(enrol(enrolled, [owner.userId]));
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
expectUnchanged(await listGuilds(member.token, true), memberBaseline);
expectUnchanged(await listGuilds(owner.token, false), ownerBaseline);
await setConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
await setConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
await setConfig({...enrol(enrolled, [owner.userId]), enabled: false});
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), enabled: false});
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
});