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

@@ -4,7 +4,6 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs';
import {
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
@@ -15,7 +14,6 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getChannelThreadsConfigPublisher,
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushRelayConfigPublisher,
@@ -23,11 +21,6 @@ import {
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
enabledThreadGuildIds,
enqueueThreadPermissionSeeds,
newlyEnabledThreadGuildIds,
} from '@app/api/worker/tasks/SeedThreadPermissions';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
@@ -42,13 +35,8 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -78,9 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
pushRelay,
domainMigration,
plutoniumPage,
captcha,
channelThreads,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -90,9 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getChannelThreadsConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -129,9 +113,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
push_relay: pushRelay,
domain_migration: domainMigration,
plutonium_page: plutoniumPage,
captcha,
channel_threads: channelThreads,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -414,43 +396,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.plutonium_page) {
const patch = omitUndefinedFields(data.plutonium_page);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updatePlutoniumPageConfig((current) =>
PlutoniumPageConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateCaptchaConfig(patch);
}
}
let channelThreadsConfigVersion: number | undefined;
if (data.channel_threads) {
const patch = omitUndefinedFields(data.channel_threads);
if (Object.keys(patch).length > 0) {
let previous: ChannelThreadsConfig | undefined;
const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => {
previous = current;
return applyChannelThreadsConfigUpdate(current, patch);
});
channelThreadsConfigVersion = landed.config_version;
await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed);
const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed);
for (const guildId of newlyEnabled) {
await enqueueRebuildThreadAutoArchiveQueue(guildId);
await enqueueThreadSearchBackfill(guildId);
}
await getChannelThreadsConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
@@ -646,7 +597,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
action: 'update_instance_config',
metadata: {
sections: listSuppliedSections(data),
channel_threads_config_version: channelThreadsConfigVersion?.toString(),
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
},
});
@@ -8,13 +8,11 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {vi} from 'vitest';
async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise<string> {
const created = await createBuilder<CreateRegistrationUrlResponse>(harness, admin.token)
@@ -82,26 +80,6 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'PATCH',
route: '/admin/instance/config',
name: 'channel threads',
async prepare() {
vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
return {
request: {
path: '/admin/instance/config',
body: {channel_threads: {enabled: true, enabled_guild_ids: ['1']}},
},
expected: {
action: 'update_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {sections: 'channel_threads', channel_threads_config_version: '1'},
},
};
},
},
{
method: 'POST',
route: '/admin/instance/config/branding-assets',
@@ -15,7 +15,7 @@ import type {IUserRepository} from '@app/api/user/IUserRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterEach, describe, expect, it} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
const GUILD_ID = createGuildID(100n);
const CHANNEL_ID = createChannelID(300n);
@@ -54,7 +54,7 @@ function enroll(): void {
const capableViewer: ThreadViewer = {kind: 'user', userId: USER_ID, bot: false, capable: true};
describe('getChannelAuthenticated thread gate', () => {
afterEach(() => {
beforeEach(() => {
syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({}));
});
@@ -2,14 +2,15 @@
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
getCompiledChannelThreadsConfig,
pinChannelThreadsConfigForTesting,
} from '@app/api/experiment/ChannelThreadsGate';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
DEFAULT_CHANNEL_THREADS_CONFIG,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
export const THREADS_FEATURE_HEADER = 'X-Fluxer-Features';
@@ -22,7 +23,8 @@ export const ALL_THREADS_ACTIVE: Partial<ChannelThreadsConfig> = {
};
export async function setChannelThreadsConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
const current = getCompiledChannelThreadsConfig().config;
pinChannelThreadsConfigForTesting(
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
@@ -33,7 +35,7 @@ export async function setChannelThreadsConfig(patch: Partial<ChannelThreadsConfi
}
export function resetChannelThreadsConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
pinChannelThreadsConfigForTesting(DEFAULT_CHANNEL_THREADS_CONFIG);
clearChannelThreadsTaintCacheForTesting();
}
@@ -28,14 +28,6 @@ export async function enqueueRemoveThreadMemberships(guildId: GuildID, userId: U
);
}
export async function enqueueRebuildThreadAutoArchiveQueue(guildId: string): Promise<void> {
await (await workerService()).addJob(
'rebuildThreadAutoArchiveQueue',
{guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version},
{jobKey: `rebuild-thread-archive-queue-${guildId}`},
);
}
export function enqueueRepairThreadIndexes(threadIds: Array<ChannelID>): void {
if (threadIds.length === 0) return;
const window = Math.floor(Date.now() / REPAIR_DEBOUNCE_MS);
@@ -2,21 +2,14 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createDmChannel, createFriendship, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
applyChannelThreadsConfigUpdate,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
class RecordingExecutor extends InMemoryCassandraQueryExecutor {
@@ -38,11 +31,6 @@ class RecordingExecutor extends InMemoryCassandraQueryExecutor {
}
}
function resetConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('channel threads control identity', () => {
let harness: ApiTestHarness;
let executor: RecordingExecutor;
@@ -53,13 +41,13 @@ describe('channel threads control identity', () => {
beforeEach(async () => {
await harness.reset();
resetConfig();
resetChannelThreadsConfig();
executor = new RecordingExecutor();
setCassandraQueryExecutorForTesting(executor);
});
afterEach(() => {
resetConfig();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -93,10 +81,8 @@ describe('channel threads control identity', () => {
const baselineDm = await recorded(() => createDmChannel(harness, baseline.owner.token, baseline.friend.userId));
const baselineChannels = await loadChannels(baselineGuild.result.id);
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['1']}),
);
expect((await getInstanceConfigRepository().refreshChannelThreadsConfig()).config.ever_enabled).toBe(true);
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: ['1']});
expect(getCompiledChannelThreadsConfig().config.ever_enabled).toBe(true);
const enrolledOff = await createPair();
const guild = await recorded(() => createGuild(harness, enrolledOff.owner.token, 'control'));
@@ -3,22 +3,20 @@
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes';
import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
import {BotOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
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 {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {z} from 'zod';
@@ -96,6 +94,7 @@ describe('ChannelThreadsRouteGuard', () => {
beforeEach(async () => {
await harness.reset();
resetChannelThreadsConfig();
executor = new RecordingExecutor();
setCassandraQueryExecutorForTesting(executor);
owner = await createTestAccount(harness);
@@ -109,10 +108,8 @@ describe('ChannelThreadsRouteGuard', () => {
await harness.shutdown();
});
async function setConfig(update: ChannelThreadsConfigUpdateRequest): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, update),
);
async function setConfig(update: Partial<ChannelThreadsConfig>): Promise<void> {
await setChannelThreadsConfig({...getCompiledChannelThreadsConfig().config, ...update});
}
async function snapshot(
@@ -13,7 +13,7 @@ import {
channelThreadsUserActive,
channelThreadsUserExcluded,
compileChannelThreadsConfig,
DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {Context} from 'hono';
@@ -39,19 +39,31 @@ const FETCH_GUILD_THREAD_STATE_CQL = GuildThreadState.selectCql({
const taintCache = new Map<string, {tainted: boolean; checkedAt: number}>();
let compiled: CompiledChannelThreadsConfig = DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG;
const INITIAL_COMPILED_CONFIG = compileChannelThreadsConfig(everyoneChannelThreadsConfig(0));
let compiled: CompiledChannelThreadsConfig = INITIAL_COMPILED_CONFIG;
let compiledSource: string | null | undefined;
let compiledParser: ((raw: string | null) => ChannelThreadsConfig) | undefined;
let pinnedForTesting = false;
export function syncChannelThreadsConfig(
raw: string | null,
parse: (raw: string | null) => ChannelThreadsConfig,
): CompiledChannelThreadsConfig {
if (raw === compiledSource) return compiled;
compiled = raw === null ? DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG : compileChannelThreadsConfig(parse(raw));
if (pinnedForTesting || (raw === compiledSource && parse === compiledParser)) return compiled;
compiled = compileChannelThreadsConfig(parse(raw));
compiledSource = raw;
compiledParser = parse;
return compiled;
}
export function pinChannelThreadsConfigForTesting(config: ChannelThreadsConfig | null): void {
compiled = config === null ? INITIAL_COMPILED_CONFIG : compileChannelThreadsConfig(config);
compiledSource = undefined;
compiledParser = undefined;
pinnedForTesting = config !== null;
}
export function getCompiledChannelThreadsConfig(): CompiledChannelThreadsConfig {
return compiled;
}
@@ -11,7 +11,6 @@ import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveChannelThreadsAssignment} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolvePlutoniumPageAssignment} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
export function ExperimentController(app: HonoApp) {
@@ -31,21 +30,20 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, domainMigrationConfig, channelThreadsConfig, plutoniumPageConfig] = await Promise.all([
const [delivery, domainMigrationConfig, channelThreadsConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getCompiledChannelThreadsConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, plutoniumPageConfig]);
const targeting = await resolveExperimentTargeting(user, domainMigrationConfig);
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
plutonium_page: resolvePlutoniumPageAssignment(plutoniumPageConfig, userId, targeting),
plutonium_page: {enabled: true},
},
};
const channelThreads = resolveChannelThreadsAssignment(channelThreadsConfig, userId);
@@ -13,9 +13,9 @@ const NO_GUILDS: ReadonlySet<string> = new Set();
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
config: TargetableExperimentConfig,
): Promise<ExperimentTargeting> {
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
const needsGuilds = config.enabled && config.included_guild_ids.length > 0;
const memberGuildIds = needsGuilds
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
: NO_GUILDS;
@@ -25,6 +25,7 @@ import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
@@ -65,17 +66,17 @@ describe('ChannelThreadsGate', () => {
});
afterEach(() => {
syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({}));
load({});
setCassandraQueryExecutorForTesting(null);
});
it('is off for everyone while the config is missing', () => {
syncChannelThreadsConfig(null, () => {
throw new Error('a missing config is never parsed');
});
expect(guildActive(GUILD)).toBe(false);
expect(userActive(USER)).toBe(false);
expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(false);
it('hands a missing config to the parser', () => {
load({enabled: false});
const compiled = syncChannelThreadsConfig(null, (raw) => everyoneChannelThreadsConfig(raw === null ? 3 : 0));
expect(compiled.config.config_version).toBe(3);
expect(guildActive(GUILD)).toBe(true);
expect(userActive(USER)).toBe(true);
expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(true);
});
it('parses a snapshot once and reuses the compiled value while the string is unchanged', () => {
@@ -2,7 +2,6 @@
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
@@ -10,18 +9,11 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {DEFAULT_CHANNEL_THREADS_CONFIG} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_PLUTONIUM_PAGE_CONFIG,
INERT_PLUTONIUM_PAGE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import {
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
@@ -31,7 +23,7 @@ import {
readDomainMigrationAssignment,
readPlutoniumPageAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const NOT_MODIFIED = 304;
const ENDPOINT = '/experiments';
@@ -47,10 +39,6 @@ describe('GET /experiments', () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
@@ -59,7 +47,7 @@ describe('GET /experiments', () => {
await createBuilderWithoutAuth(harness).get(ENDPOINT).expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
it('returns the default delivery cadence and the inert assignment while the feature is disabled', async () => {
it('returns the default delivery cadence and the default assignments', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
@@ -69,7 +57,8 @@ describe('GET /experiments', () => {
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
plutonium_page: INERT_PLUTONIUM_PAGE_ASSIGNMENT,
channel_threads: {active: true, config_version: 0},
plutonium_page: {enabled: true},
},
});
});
@@ -83,13 +72,16 @@ describe('GET /experiments', () => {
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
});
it('populates the plutonium page assignment key even when the rollout is disabled', async () => {
it('serves the plutonium page to everyone and ignores a leftover rollout row', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setConfig(
'plutonium_page_config',
JSON.stringify({enabled: false, config_version: 3, rollout_basis_points: 0}),
);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'plutonium_page')).toBe(true);
expect(readPlutoniumPageAssignment(body).enabled).toBe(false);
expect(readPlutoniumPageAssignment(body)).toEqual({enabled: true});
});
it('resolves the domain migration caller through the allowlist', async () => {
@@ -129,54 +121,7 @@ describe('GET /experiments', () => {
expect(body.assignments.domain_migration).toEqual({enabled: false});
});
it('resolves the plutonium page caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
rollout_basis_points: 0,
included_user_ids: [targeted.userId, excluded.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.plutonium_page).toEqual({enabled: true});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(untargetedBody.assignments.plutonium_page).toEqual({enabled: false});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false});
});
it('keeps the plutonium page exclusion ahead of a full rollout', async () => {
const excluded = await createTestAccount(harness);
const other = await createTestAccount(harness);
await getInstanceConfigRepository().setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
excluded_user_ids: [excluded.userId],
});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false});
const otherBody = await createBuilder<ExperimentAssignmentsResponse>(harness, other.token).get(ENDPOINT).execute();
expect(otherBody.assignments.plutonium_page).toEqual({enabled: true});
});
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
it('enrols members of an included guild in domain migration and leaves everyone else out', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
@@ -184,29 +129,21 @@ describe('GET /experiments', () => {
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
const repository = getInstanceConfigRepository();
await repository.setDomainMigrationConfig({
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.plutonium_page).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.plutonium_page).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
@@ -215,17 +152,11 @@ describe('GET /experiments', () => {
const free = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
const repository = getInstanceConfigRepository();
await repository.setDomainMigrationConfig({
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
include_premium_users: true,
});
await repository.setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
include_premium_users: true,
});
for (const [account, expected] of [
[subscriber, true],
[visionary, true],
@@ -233,30 +164,24 @@ describe('GET /experiments', () => {
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.domain_migration).toEqual({enabled: expected});
expect(body.assignments.plutonium_page).toEqual({enabled: expected});
}
});
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
it('stores the guild ids and premium switch an admin sets for domain migration', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<'domain_migration' | 'plutonium_page', {included_guild_ids: Array<string>; include_premium_users: boolean}>
>(harness, admin.token)
const body = await createBuilder<{
domain_migration: {included_guild_ids: Array<string>; include_premium_users: boolean};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
plutonium_page: {included_guild_ids: guildIds, include_premium_users: true},
})
.body({domain_migration: {included_guild_ids: guildIds, include_premium_users: true}})
.execute();
for (const section of [body.domain_migration, body.plutonium_page]) {
expect(section.included_guild_ids).toEqual(guildIds);
expect(section.include_premium_users).toBe(true);
}
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.domain_migration.include_premium_users).toBe(true);
});
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
@@ -411,38 +336,23 @@ describe('GET /experiments', () => {
expect(afterUndefined.domain_migration).toMatchObject({config_version: 1, enabled: true});
});
it('bumps the plutonium page config version on every admin update without the client sending one', async () => {
it('no longer exposes or accepts plutonium page and channel threads settings in the admin config', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{plutonium_page: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
const updated = await createBuilder<Record<string, unknown>>(harness, admin.token)
.patch('/admin/instance/config')
.body({plutonium_page: {enabled: true, included_user_ids: [admin.userId]}})
.body({plutonium_page: {enabled: false}, channel_threads: {enabled: false}})
.execute();
expect(afterFirst.plutonium_page).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
plutonium_page: {config_version: number; rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({plutonium_page: {rollout_basis_points: 2500}})
.execute();
expect(afterSecond.plutonium_page).toMatchObject({config_version: 2, rollout_basis_points: 2500});
const afterEmpty = await createBuilder<{plutonium_page: {config_version: number}}>(harness, admin.token)
.patch('/admin/instance/config')
.body({plutonium_page: {}})
.execute();
expect(afterEmpty.plutonium_page).toMatchObject({config_version: 2});
expect(Object.hasOwn(updated, 'plutonium_page')).toBe(false);
expect(Object.hasOwn(updated, 'channel_threads')).toBe(false);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.plutonium_page).toEqual({enabled: true});
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 0});
});
it('serves the delivery cadence an admin set through the instance config', async () => {
@@ -466,67 +376,20 @@ describe('GET /experiments', () => {
expect(body.poll_jitter_percent).toBe(DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT);
});
it('keeps the control body and etag for a user outside the channel threads experiment', async () => {
it('assigns channel threads to every user with the stored config version, even from a disabled row', async () => {
const account = await createTestAccount(harness);
const before = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.executeWithResponse();
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: ['1']}),
);
const after = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.executeWithResponse();
expect(Object.hasOwn(after.json.assignments, 'channel_threads')).toBe(false);
expect(readChannelThreadsAssignment(after.json)).toBeNull();
expect(after.response.headers.get('etag')).toBe(before.response.headers.get('etag'));
});
it('assigns channel threads to an included user with the config version', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: [account.userId]}),
await getInstanceConfigRepository().setConfig(
'channel_threads_config',
JSON.stringify({
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: false,
config_version: 7,
excluded_user_ids: [account.userId],
}),
);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 1});
});
it('bumps the channel threads version, keeps ever_enabled sticky and publishes every admin update', async () => {
const publish = vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patch = (body: Record<string, unknown>) =>
createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token)
.patch('/admin/instance/config')
.body({channel_threads: body})
.execute();
const initial = await createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(initial.channel_threads).toMatchObject({enabled: false, config_version: 0, ever_enabled: false});
const enabled = await patch({enabled: true, included_user_ids: [admin.userId], ever_enabled: false});
expect(enabled.channel_threads).toMatchObject({enabled: true, config_version: 1, ever_enabled: true});
const disabled = await patch({enabled: false, config_version: 99});
expect(disabled.channel_threads).toMatchObject({enabled: false, config_version: 2, ever_enabled: true});
const unchanged = await patch({});
expect(unchanged.channel_threads.config_version).toBe(2);
expect(publish.mock.calls.map(([config]) => [config.config_version, config.enabled])).toEqual([
[1, true],
[2, false],
]);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(readChannelThreadsAssignment(body)).toBeNull();
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 7});
});
});
@@ -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);
});
@@ -70,7 +70,7 @@ describe('KVThreadAutoArchiveQueueService', () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
kv = new MockKVProvider();
repositories = new ChannelRepository();
queue = new KVThreadAutoArchiveQueueService(kv, repositories.threads, repositories.channelData);
queue = new KVThreadAutoArchiveQueueService(kv);
base = Date.now() - 10 * HOUR;
await upsertOne(Channels.upsertAll({...threadRow(PARENT_ID), type: ChannelTypes.GUILD_TEXT, parent_id: null}));
});
@@ -134,14 +134,4 @@ describe('KVThreadAutoArchiveQueueService', () => {
await queue.schedule(transition!.state, null);
expect(await kv.zcard(threadArchiveQueueKey(GUILD_ID))).toBe(0);
});
it('rebuilds a guild from its active partition', async () => {
const first = await createThread(0, 60);
const second = await createThread(1000, 60);
await createThread(2000, 60, ChannelFlags.PINNED);
await kv.zadd(threadArchiveQueueKey(GUILD_ID), 1, '123');
expect(await queue.rebuildGuild(GUILD_ID)).toBe(2);
expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([first.threadId, second.threadId]);
expect(await queue.getDue(GUILD_ID, Date.now(), 1)).toEqual([first.threadId]);
});
});
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type ChannelID, createChannelID, createGuildID, type GuildID, type MessageID} from '@app/api/BrandedTypes';
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
import type {ThreadState} from '@app/api/models/ThreadState';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
@@ -20,11 +18,7 @@ export function threadAutoArchiveDueAt(state: ThreadState, lastMessageId: Messag
}
export class KVThreadAutoArchiveQueueService {
constructor(
private readonly kvClient: IKVProvider,
private readonly threads: IThreadRepository,
private readonly channelData: IChannelDataRepository,
) {}
constructor(private readonly kvClient: IKVProvider) {}
async schedule(state: ThreadState, lastMessageId: MessageID | null): Promise<void> {
if (state.archived || state.isPinned) {
@@ -63,23 +57,4 @@ export class KVThreadAutoArchiveQueueService {
const members = await this.kvClient.smembers(THREAD_ARCHIVE_GUILDS_KEY);
return members.flatMap((member) => (/^\d+$/.test(member) ? [createGuildID(BigInt(member))] : []));
}
async rebuildGuild(guildId: GuildID): Promise<number> {
const key = threadArchiveQueueKey(guildId);
const states = (await this.threads.listActiveThreads(guildId)).filter((state) => !state.isPinned);
const channels = await this.channelData.listChannels(states.map((state) => state.threadId));
const lastMessageIds = new Map(channels.map((channel) => [channel.id, channel.lastMessageId]));
await this.kvClient.del(key);
if (states.length === 0) {
await this.kvClient.srem(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString());
return 0;
}
const scoreMembers = states.flatMap((state) => [
threadAutoArchiveDueAt(state, lastMessageIds.get(state.threadId) ?? null),
state.threadId.toString(),
]);
await this.kvClient.zadd(key, ...scoreMembers);
await this.kvClient.sadd(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString());
return states.length;
}
}
@@ -1,30 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const CHANNEL_THREADS_CONFIG_NATS_SUBJECT = 'config.channel.threads';
interface ChannelThreadsConfigNatsMessage {
type: 'channel_threads_config';
config: ChannelThreadsConfig;
}
export class ChannelThreadsConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: ChannelThreadsConfig): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
const connection = this.connectionManager.getConnection();
const message: ChannelThreadsConfigNatsMessage = {
type: 'channel_threads_config',
config,
};
connection.publish(CHANNEL_THREADS_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
await connection.flush();
}
}
@@ -18,6 +18,10 @@ import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceC
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
DEFAULT_CHANNEL_THREADS_CONFIG,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
type DomainMigrationConfig,
@@ -35,6 +39,7 @@ import {
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
@@ -301,6 +306,37 @@ describe('InstanceConfigRepository', () => {
expect(domains).not.toContain('example.com');
});
it('serves the everyone channel threads config at version zero when the key is absent', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await expect(repository.getChannelThreadsConfig()).resolves.toEqual(everyoneChannelThreadsConfig(0));
});
it.each([
{
name: 'a disabled row',
stored: JSON.stringify({
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: false,
config_version: 9,
disabled_guild_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
}),
version: 9,
},
{name: 'a partial rollout row', stored: '{"enabled":true,"config_version":4,"guild_basis_points":100}', version: 4},
{name: 'a row with an invalid version', stored: '{"enabled":false,"config_version":-1}', version: 0},
{name: 'unparseable text', stored: 'not-json', version: 0},
])('serves the everyone channel threads config for $name and keeps the stored version', async ({stored, version}) => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setConfig(CHANNEL_THREADS_CONFIG_KEY, stored);
await expect(repository.getChannelThreadsConfig()).resolves.toEqual(everyoneChannelThreadsConfig(version));
});
it('returns the default domain migration config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -53,8 +53,8 @@ import {
} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
type CompiledChannelThreadsConfig,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {
type DomainMigrationConfig,
@@ -69,10 +69,6 @@ import {
type StoredBillingConfig,
StoredBillingConfigSchema,
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
import {
type PlutoniumPageConfig,
PlutoniumPageConfigSchema,
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
@@ -100,7 +96,6 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const PLUTONIUM_PAGE_CONFIG_KEY = 'plutonium_page_config';
const CAPTCHA_CONFIG_KEY = 'captcha_config';
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
@@ -433,7 +428,6 @@ type StoredConfigSection =
| 'gateway rollout'
| 'push relay'
| 'domain migration'
| 'plutonium page'
| 'captcha'
| 'channel threads'
| 'experiment delivery'
@@ -653,16 +647,15 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredPlutoniumPageConfig(raw: string | null): PlutoniumPageConfig {
return parseStoredConfigOrDefault(PlutoniumPageConfigSchema, raw, 'plutonium page');
}
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
}
const StoredChannelThreadsVersionSchema = z.object({config_version: z.number().int().min(0)});
function parseStoredChannelThreadsConfig(raw: string | null): ChannelThreadsConfig {
return parseStoredConfigOrDefault(ChannelThreadsConfigSchema, raw, 'channel threads');
const stored = StoredChannelThreadsVersionSchema.safeParse(readStoredConfigValue(raw, 'channel threads'));
return everyoneChannelThreadsConfig(stored.success ? stored.data.config_version : 0);
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
@@ -1327,7 +1320,6 @@ export class InstanceConfigRepository {
);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredPlutoniumPageConfig(snapshot.get(PLUTONIUM_PAGE_CONFIG_KEY) ?? null);
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
syncChannelThreadsConfig(snapshot.get(CHANNEL_THREADS_CONFIG_KEY) ?? null, parseStoredChannelThreadsConfig);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
@@ -1571,23 +1563,6 @@ export class InstanceConfigRepository {
);
}
async getPlutoniumPageConfig(): Promise<PlutoniumPageConfig> {
const raw = await this.getConfig(PLUTONIUM_PAGE_CONFIG_KEY);
return parseStoredPlutoniumPageConfig(raw);
}
async setPlutoniumPageConfig(config: PlutoniumPageConfig): Promise<void> {
await this.updatePlutoniumPageConfig(() => config);
}
updatePlutoniumPageConfig(
update: (current: PlutoniumPageConfig) => PlutoniumPageConfig,
): Promise<PlutoniumPageConfig> {
return this.updateStoredConfig(PLUTONIUM_PAGE_CONFIG_KEY, (raw) =>
validateStoredConfig(PlutoniumPageConfigSchema, update(parseStoredPlutoniumPageConfig(raw)), 'plutonium page'),
);
}
async getCaptchaConfig(): Promise<CaptchaConfig> {
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
return parseStoredCaptchaConfig(raw);
@@ -1613,16 +1588,6 @@ export class InstanceConfigRepository {
return syncChannelThreadsConfig(raw, parseStoredChannelThreadsConfig);
}
async updateChannelThreadsConfig(
update: (current: ChannelThreadsConfig) => ChannelThreadsConfig,
): Promise<ChannelThreadsConfig> {
const landed = await this.updateStoredConfig(CHANNEL_THREADS_CONFIG_KEY, (raw) =>
validateStoredConfig(ChannelThreadsConfigSchema, update(parseStoredChannelThreadsConfig(raw)), 'channel threads'),
);
syncChannelThreadsConfig(JSON.stringify(landed), parseStoredChannelThreadsConfig);
return landed;
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
return parseStoredExperimentDeliveryConfig(raw);
@@ -47,7 +47,6 @@ import {createStorageService} from '@app/api/infrastructure/StorageServiceFactor
import {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
@@ -175,17 +174,6 @@ export const getPushRelayConfigPublisher = singleton(
),
);
export const getChannelThreadsConfigPublisher = singleton(
() =>
new ChannelThreadsConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-channel-threads-config',
}),
),
);
export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository());
export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()}));
export const getRateLimitService = singleton(() => new RateLimitService(getKVClient()));
@@ -278,8 +266,7 @@ let threadAutoArchiveQueue: KVThreadAutoArchiveQueueService | null = null;
export function getKVThreadAutoArchiveQueue(): KVThreadAutoArchiveQueueService {
const kvClient = getKVClient();
if (!threadAutoArchiveQueue || threadAutoArchiveQueueClient !== kvClient) {
const channels = getChannelRepository();
threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient, channels.threads, channels.channelData);
threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient);
threadAutoArchiveQueueClient = kvClient;
}
return threadAutoArchiveQueue;
@@ -5,9 +5,9 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
DEFAULT_CHANNEL_THREADS_CONFIG,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
@@ -38,29 +38,27 @@ describe('RpcService get_channel_threads_config', () => {
.expect(HTTP_STATUS.OK)
.execute();
test('serves the disabled default before any admin write', async () => {
test('serves the everyone config at version zero while no row is stored', async () => {
expect(await fetchConfig()).toEqual({
type: 'get_channel_threads_config',
data: {config: DEFAULT_CHANNEL_THREADS_CONFIG},
data: {config: everyoneChannelThreadsConfig(0)},
});
});
test('serves the stored config with its version and sticky ever_enabled', async () => {
const repository = getInstanceConfigRepository();
await repository.updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['123']}),
);
await repository.updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: false}),
test('serves the everyone config at the stored version for a disabled partial row', async () => {
await getInstanceConfigRepository().setConfig(
'channel_threads_config',
JSON.stringify({
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: false,
ever_enabled: true,
config_version: 12,
guild_basis_points: 2500,
enabled_guild_ids: ['123'],
excluded_user_ids: ['456'],
}),
);
const response = await fetchConfig();
expect(response.data.config).toMatchObject({
enabled: false,
ever_enabled: true,
config_version: 2,
enabled_guild_ids: ['123'],
});
expect((await fetchConfig()).data.config).toEqual(everyoneChannelThreadsConfig(12));
});
});
@@ -2,36 +2,28 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {executeConditional, fetchOne} from '@app/api/database/CassandraQueryExecution';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {
clearChannelThreadsTaintCacheForTesting,
getCompiledChannelThreadsConfig,
insertGuildThreadMarker,
isTainted,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {getWorkerService} from '@app/api/middleware/ServiceRegistry';
import {getChannelRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {getChannelRepository} from '@app/api/middleware/ServiceSingletons';
import {GuildThreadState} from '@app/api/Tables';
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 {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {
applyChannelThreadsConfigUpdate,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
function resetConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('RpcService guild load thread permission seeding', () => {
let harness: ApiTestHarness;
@@ -41,12 +33,12 @@ describe('RpcService guild load thread permission seeding', () => {
beforeEach(async () => {
await harness.reset();
resetConfig();
resetChannelThreadsConfig();
});
afterEach(() => {
vi.restoreAllMocks();
resetConfig();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -60,10 +52,7 @@ describe('RpcService guild load thread permission seeding', () => {
.expect(HTTP_STATUS.OK)
.execute();
const loadGuild = (guildId: string) => loadCollection(guildId, 'guild');
const setEnabled = (enabled: boolean) =>
getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled, guild_basis_points: 10000}),
);
const setEnabled = (enabled: boolean) => setChannelThreadsConfig({enabled, guild_basis_points: 10000});
test('marks and seeds a bucketed guild on its first active load, and leaves control guilds alone', async () => {
const owner = await createTestAccount(harness);
@@ -75,16 +64,14 @@ describe('RpcService guild load thread permission seeding', () => {
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).toBeNull();
expect(addJob).not.toHaveBeenCalled();
const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, guild_basis_points: 10000}),
);
await setEnabled(true);
await loadGuild(guild.id);
const marker = await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId});
expect(marker?.first_active_at).toBeInstanceOf(Date);
expect(marker?.perms_seeded_at).toBeNull();
expect(addJob).toHaveBeenCalledWith(
'seedThreadPermissions',
{guildId: guild.id, configVersion: landed.config_version},
{guildId: guild.id, configVersion: getCompiledChannelThreadsConfig().config.config_version},
{jobKey: `seed-thread-permissions-${guild.id}`},
);
@@ -1,21 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {markGuildChannelsAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
@@ -23,37 +15,21 @@ import {afterEach, beforeEach, describe, expect, test} 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 resetConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('message search thread permission bits', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness({search: 'enabled'});
resetConfig();
resetChannelThreadsConfig();
});
afterEach(async () => {
resetConfig();
resetChannelThreadsConfig();
await harness.shutdown();
});
test('search channel overwrites are masked for non-viewers in tainted guilds', async () => {
await setConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000});
await setChannelThreadsConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000});
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Search masking');
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -89,7 +65,7 @@ describe('message search thread permission bits', () => {
expect(await searchAllow(scope, false)).toBe(Permissions.SEND_MESSAGES);
}
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
for (const scope of scopes) {
expect(await searchAllow(scope, true)).toBe(Permissions.SEND_MESSAGES);
}
+2
View File
@@ -11,6 +11,7 @@ import {
setCassandraQueryExecutorForTesting,
shutdownCassandraQueryExecutorForTesting,
} from '@app/api/database/CassandraQueryExecution';
import {pinChannelThreadsConfigForTesting} from '@app/api/experiment/ChannelThreadsGate';
import type {IUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {setInjectedUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {initializeLogger} from '@app/api/Logger';
@@ -227,6 +228,7 @@ afterEach(async () => {
});
afterAll(async () => {
pinChannelThreadsConfigForTesting(null);
server.close();
await shutdownCassandraQueryExecutorForTesting();
});
@@ -27,7 +27,7 @@ import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domain
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const {isUnreachableEntityError} = UserContentServiceTestHooks;
@@ -255,7 +255,7 @@ describe('getRecentMentions', () => {
const THREAD = createChannelID(400n);
const viewer = {kind: 'user', userId: VIEWER_ID, bot: false, capable: false} as const;
afterEach(() => {
beforeEach(() => {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
});
@@ -447,7 +447,7 @@ describe('getSavedMessages', () => {
);
}
afterEach(() => {
beforeEach(() => {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
});
@@ -87,7 +87,6 @@ const LANE_CONFIG = {
'refreshSearchIndex',
'seedThreadPermissions',
'archiveInactiveThreads',
'rebuildThreadAutoArchiveQueue',
'repairThreadIndexes',
'deleteChannelThreads',
'removeThreadMembershipsForGuildMember',
@@ -55,7 +55,6 @@ import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
import {
archiveInactiveThreads,
deleteChannelThreads,
rebuildThreadAutoArchiveQueue,
removeThreadMembershipsForGuildMember,
repairThreadIndexes,
} from '@app/api/worker/tasks/ThreadMaintenanceTasks';
@@ -69,7 +68,6 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
applicationProcessDeletion,
archiveInactiveThreads,
deleteChannelThreads,
rebuildThreadAutoArchiveQueue,
removeThreadMembershipsForGuildMember,
repairThreadIndexes,
syncThreadSearchDocument,
@@ -13,11 +13,6 @@ import type {Channel} from '@app/api/models/Channel';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DEFAULT_THREAD_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
type ChannelThreadsConfig,
channelThreadsGuildActive,
compileChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
@@ -27,7 +22,6 @@ const PayloadSchema = z.object({
});
const SEEDED_GUILD_CACHE_MAX_ENTRIES = 10_000;
const SEED_MARKER_READ_CHUNK = 100;
const seededGuildIds = new Set<string>();
export function seedThreadOverwriteBits(value: bigint): bigint {
@@ -39,33 +33,6 @@ export function seedThreadOverwriteValue(value: bigint): bigint {
return (value & Permissions.SEND_MESSAGES) !== 0n ? cleared | DEFAULT_THREAD_PERMISSIONS : cleared;
}
export function enabledThreadGuildIds(config: ChannelThreadsConfig): Array<string> {
const compiled = compileChannelThreadsConfig(config);
return config.enabled_guild_ids.filter((id) => channelThreadsGuildActive(compiled, id));
}
export function newlyEnabledThreadGuildIds(
previous: ChannelThreadsConfig,
landed: ChannelThreadsConfig,
): Array<string> {
const before = new Set(enabledThreadGuildIds(previous));
return enabledThreadGuildIds(landed).filter((id) => !before.has(id));
}
export async function enqueueThreadPermissionSeeds(
threads: IThreadRepository,
config: ChannelThreadsConfig,
): Promise<void> {
const guildIds = enabledThreadGuildIds(config);
for (let index = 0; index < guildIds.length; index += SEED_MARKER_READ_CHUNK) {
const chunk = guildIds.slice(index, index + SEED_MARKER_READ_CHUNK);
const markers = await Promise.all(chunk.map((id) => threads.getGuildMarker(createGuildID(BigInt(id)))));
for (const [position, guildId] of chunk.entries()) {
if (!markers[position]?.perms_seeded_at) await enqueueThreadPermissionSeed(guildId);
}
}
}
async function enqueueThreadPermissionSeed(guildId: string): Promise<void> {
await getWorkerService().addJob(
'seedThreadPermissions',
@@ -22,14 +22,12 @@ import {
import {Logger} from '@app/api/Logger';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService';
import {ensureChannelThreadsConfigVersion} from '@app/api/worker/tasks/SeedThreadPermissions';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {MAX_THREAD_ARCHIVES_PER_SWEEP_TICK} from '@fluxer/constants/src/ThreadConstants';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
const SnowflakeString = z.string().regex(/^\d{1,20}$/);
const GuildPayload = z.object({guildId: SnowflakeString, configVersion: z.number().int().min(0).optional()});
const RepairPayload = z.object({threadIds: z.array(SnowflakeString).min(1).max(1000)});
const ParentPayload = z.object({guildId: SnowflakeString, parentId: SnowflakeString});
const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeString});
@@ -37,8 +35,8 @@ const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeStrin
const PARENT_PAGE_SIZE = 100;
function archiveQueue(): KVThreadAutoArchiveQueueService {
const {kvClient, channelRepository} = getWorkerDependencies();
return new KVThreadAutoArchiveQueueService(kvClient, channelRepository.threads, channelRepository.channelData);
const {kvClient} = getWorkerDependencies();
return new KVThreadAutoArchiveQueueService(kvClient);
}
async function archiveDueThreads(
@@ -109,15 +107,6 @@ export const archiveInactiveThreads: WorkerTaskHandler = async () => {
if (archived > 0) Logger.info({archived}, 'Archived inactive threads');
};
export const rebuildThreadAutoArchiveQueue: WorkerTaskHandler = async (payload) => {
const validated = GuildPayload.parse(payload);
await ensureChannelThreadsConfigVersion(validated.configVersion);
const guildId = createGuildID(BigInt(validated.guildId));
if (!guildActive(guildId)) return;
const scheduled = await archiveQueue().rebuildGuild(guildId);
Logger.info({guildId: guildId.toString(), scheduled}, 'Rebuilt thread auto-archive queue');
};
export const repairThreadIndexes: WorkerTaskHandler = async (payload) => {
const {threadIds} = RepairPayload.parse(payload);
const {channelRepository} = getWorkerDependencies();
@@ -349,6 +349,7 @@ describe('thread mention chunks', () => {
});
test('drops a thread chunk that lands after the guild left the experiment', async () => {
activate(false);
const increments = chunkHarness();
await runChunk();
expect(increments).toEqual([]);
@@ -8,7 +8,7 @@ import {fetchOne} from '@app/api/database/CassandraQueryExecution';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
pinChannelThreadsConfigForTesting,
} from '@app/api/experiment/ChannelThreadsGate';
import {addMemberRole, createGuild, createRole} from '@app/api/guild/tests/GuildTestUtils';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -35,12 +35,8 @@ import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSc
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
function parseConfig(raw: string | null): ChannelThreadsConfig {
return ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {});
}
function setConfig(patch: Partial<ChannelThreadsConfig> | null): void {
syncChannelThreadsConfig(patch === null ? null : JSON.stringify(patch), parseConfig);
pinChannelThreadsConfigForTesting(ChannelThreadsConfigSchema.parse(patch ?? {}));
clearChannelThreadsTaintCacheForTesting();
}
@@ -195,22 +191,19 @@ describe('seedThreadPermissions', () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Lagging');
const guildId = createGuildID(BigInt(guild.id));
const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
ChannelThreadsConfigSchema.parse({
enabled: true,
ever_enabled: true,
enabled_guild_ids: [guild.id],
config_version: current.config_version + 1,
}),
const storedVersion = 5;
await getInstanceConfigRepository().setConfig(
'channel_threads_config',
JSON.stringify(ChannelThreadsConfigSchema.parse({enabled: false, config_version: storedVersion})),
);
setConfig(null);
pinChannelThreadsConfigForTesting(null);
await expect(
seedThreadPermissions({guildId: guild.id, configVersion: landed.config_version + 1}, helpers()),
seedThreadPermissions({guildId: guild.id, configVersion: storedVersion + 1}, helpers()),
).rejects.toThrow();
setConfig(null);
pinChannelThreadsConfigForTesting(null);
await seedThreadPermissions({guildId: guild.id, configVersion: landed.config_version}, helpers());
await seedThreadPermissions({guildId: guild.id, configVersion: storedVersion}, helpers());
const marker = await fetchOne<GuildThreadStateRow>(
GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}),
{guild_id: guildId},
@@ -23,7 +23,6 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {
archiveInactiveThreads,
deleteChannelThreads,
rebuildThreadAutoArchiveQueue,
removeThreadMembershipsForGuildMember,
} from '@app/api/worker/tasks/ThreadMaintenanceTasks';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
@@ -130,21 +129,6 @@ describe('thread maintenance tasks', () => {
expect(queued).not.toContain(thread.id);
});
it('reloads a lagging config before rebuilding the auto-archive queue', async () => {
const s = await setup();
const thread = await startThread(s.owner.token, s.channelId);
const key = threadArchiveQueueKey(createGuildID(BigInt(s.guildId)));
await getKVClient().del(key);
const configVersion = (await getInstanceConfigRepository().getChannelThreadsConfig()).config_version;
resetChannelThreadsConfig();
await expect(
rebuildThreadAutoArchiveQueue({guildId: s.guildId, configVersion: configVersion + 1}, helpers()),
).rejects.toThrow();
resetChannelThreadsConfig();
await rebuildThreadAutoArchiveQueue({guildId: s.guildId, configVersion}, helpers());
expect(await getKVClient().zrangebyscore(key, '-inf', '+inf')).toContain(thread.id);
});
it('deletes the threads of a deleted parent in tainted guilds', async () => {
const s = await setup();
const thread = await startThread(s.owner.token, s.channelId);