From f8505d19f748199c8d4dc43fbfe6ac99a8a1fce9 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 16:30:39 +0200 Subject: [PATCH] feat(threads): ship threads and the Plutonium page to everyone (#3277) --- fluxer_admin/openapi-admin.json | 175 +----------- fluxer_admin/src/api/types/instance_config.rs | 147 ---------- fluxer_admin/src/routes/guild_tabs.rs | 9 - fluxer_admin/src/routes/system_actions.rs | 183 +------------ .../pages/guild_detail_tabs/threads.rs | 25 +- .../src/templates/pages/instance_config.rs | 254 +----------------- fluxer_admin/tests/api_deserialization.rs | 92 ------- fluxer_admin/tests/htmx_acceptance.rs | 67 +---- .../InstanceConfigAdminController.ts | 50 ---- .../InstanceConfigAdminAuditCases.ts | 22 -- .../tests/ChannelAuthThreadGate.test.ts | 4 +- .../src/api/channel/tests/ThreadTestUtils.ts | 10 +- .../src/api/channel/threads/ThreadJobs.ts | 8 - .../ChannelThreadsControlIdentity.test.ts | 26 +- .../tests/ChannelThreadsRouteGuard.test.ts | 15 +- .../src/api/experiment/ChannelThreadsGate.ts | 20 +- .../api/experiment/ExperimentController.ts | 8 +- .../src/api/experiment/ExperimentTargeting.ts | 4 +- .../tests/ChannelThreadsGate.test.ts | 17 +- .../tests/ExperimentController.test.ts | 211 +++------------ .../services/ThreadPermissionBits.test.ts | 79 +----- .../tests/ThreadPermissionBitsGate.test.ts | 56 ++-- .../tests/UserGuildsThreadsActive.test.ts | 41 +-- .../KVThreadAutoArchiveQueueService.test.ts | 12 +- .../KVThreadAutoArchiveQueueService.ts | 27 +- .../instance/ChannelThreadsConfigPublisher.ts | 30 --- .../instance/InstanceConfigRepository.test.ts | 36 +++ .../api/instance/InstanceConfigRepository.ts | 45 +--- .../src/api/middleware/ServiceSingletons.ts | 15 +- .../rpc/tests/RpcChannelThreadsConfig.test.ts | 34 ++- .../tests/RpcGuildThreadSeedTrigger.test.ts | 29 +- .../MessageSearchThreadPermissionBits.test.ts | 34 +-- fluxer_api/src/api/test/Setup.ts | 2 + .../user/services/UserContentService.test.ts | 6 +- fluxer_api/src/api/worker/WorkerLaneConfig.ts | 1 - .../src/api/worker/WorkerTaskRegistry.ts | 2 - .../api/worker/tasks/SeedThreadPermissions.ts | 33 --- .../worker/tasks/ThreadMaintenanceTasks.ts | 15 +- .../worker/tests/HandleMentionsThread.test.ts | 1 + .../tests/SeedThreadPermissions.test.ts | 27 +- .../tests/ThreadMaintenanceTasks.test.ts | 16 -- .../src/content/docs/admin-api/instance.mdx | 27 +- .../src/content/docs/admin-api/threads.mdx | 38 +-- .../src/content/docs/gateway/threads.md | 4 +- .../src/content/docs/http-api/experiments.mdx | 10 +- .../src/content/docs/http-api/forums.mdx | 4 +- .../content/docs/http-api/thread-members.mdx | 4 +- .../src/content/docs/http-api/threads.mdx | 6 +- .../schema/src/domains/admin/AdminSchemas.ts | 14 +- .../admin/ChannelThreadsSchemas.test.ts | 61 ++--- .../domains/admin/ChannelThreadsSchemas.ts | 59 +--- .../admin/PlutoniumPageSchemas.test.ts | 123 --------- .../src/domains/admin/PlutoniumPageSchemas.ts | 66 +---- 53 files changed, 296 insertions(+), 2008 deletions(-) delete mode 100644 fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts delete mode 100644 packages/schema/src/domains/admin/PlutoniumPageSchemas.test.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 62308874d..542614621 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -5491,7 +5491,7 @@ "Guild member search index", "Favourite meme search index", "Discovery guild search index", - "Thread search index (channel_threads experiment)" + "Thread search index" ], "enum": [ "guilds", @@ -10701,9 +10701,7 @@ "gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"}, "push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"}, "domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"}, - "plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageConfigResponse"}, "captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"}, - "channel_threads": {"$ref": "#/components/schemas/ChannelThreadsConfigResponse"}, "experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"}, "registration": { "type": "object", @@ -11116,9 +11114,7 @@ "gateway_rollout", "push_relay", "domain_migration", - "plutonium_page", "captcha", - "channel_threads", "experiment_delivery", "registration", "self_hosted", @@ -11256,15 +11252,7 @@ "nullable": true, "allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}] }, - "plutonium_page": { - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/PlutoniumPageConfigUpdateRequest"}] - }, "captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]}, - "channel_threads": { - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/ChannelThreadsConfigUpdateRequest"}] - }, "experiment_delivery": { "nullable": true, "allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}] @@ -15752,36 +15740,6 @@ "poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50} } }, - "ChannelThreadsConfigUpdateRequest": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "guild_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, - "guild_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"}, - "enabled_guild_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "disabled_guild_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "user_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, - "user_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"}, - "included_user_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "excluded_user_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - } - } - }, "CaptchaConfigUpdateRequest": { "type": "object", "properties": { @@ -15790,30 +15748,6 @@ "max_counter": {"type": "integer", "minimum": 100, "maximum": 20000} } }, - "PlutoniumPageConfigUpdateRequest": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, - "rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"}, - "included_user_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "included_guild_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "include_premium_users": {"type": "boolean"}, - "excluded_user_ids": { - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - } - } - }, "DomainMigrationConfigUpdateRequest": { "type": "object", "properties": { @@ -15960,68 +15894,6 @@ "required": ["poll_interval_seconds", "poll_jitter_percent"], "additionalProperties": false }, - "ChannelThreadsConfigResponse": { - "type": "object", - "properties": { - "enabled": {"default": false, "type": "boolean"}, - "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, - "ever_enabled": {"default": false, "type": "boolean"}, - "guild_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, - "guild_salt": { - "default": "channel-threads-guild-v1", - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[\\x20-\\x7e]+$" - }, - "enabled_guild_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "disabled_guild_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "user_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, - "user_salt": { - "default": "channel-threads-user-v1", - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[\\x20-\\x7e]+$" - }, - "included_user_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "excluded_user_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - } - }, - "required": [ - "enabled", - "config_version", - "ever_enabled", - "guild_basis_points", - "guild_salt", - "enabled_guild_ids", - "disabled_guild_ids", - "user_basis_points", - "user_salt", - "included_user_ids", - "excluded_user_ids" - ], - "additionalProperties": false - }, "CaptchaConfigResponse": { "type": "object", "properties": { @@ -16032,51 +15904,6 @@ "required": ["enabled", "cost", "max_counter"], "additionalProperties": false }, - "PlutoniumPageConfigResponse": { - "type": "object", - "properties": { - "enabled": {"default": false, "type": "boolean"}, - "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, - "rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, - "rollout_salt": { - "default": "plutonium-page-v1", - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[\\x20-\\x7e]+$" - }, - "included_user_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "included_guild_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - }, - "include_premium_users": {"default": false, "type": "boolean"}, - "excluded_user_ids": { - "default": [], - "maxItems": 1000, - "type": "array", - "items": {"type": "string", "pattern": "^\\d{1,20}$"} - } - }, - "required": [ - "enabled", - "config_version", - "rollout_basis_points", - "rollout_salt", - "included_user_ids", - "included_guild_ids", - "include_premium_users", - "excluded_user_ids" - ], - "additionalProperties": false - }, "DomainMigrationConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index 005ffdf39..6f5d89463 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -27,12 +27,8 @@ pub struct InstanceConfigResponse { #[serde(default)] pub domain_migration: DomainMigrationConfigResponse, #[serde(default)] - pub plutonium_page: PlutoniumPageConfigResponse, - #[serde(default)] pub captcha: CaptchaConfigResponse, #[serde(default)] - pub channel_threads: ChannelThreadsConfigResponse, - #[serde(default)] pub experiment_delivery: ExperimentDeliveryConfigResponse, #[serde(default)] pub billing: InstanceBillingResponse, @@ -509,11 +505,8 @@ impl VoiceE2eeScope { pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000; pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1"; -pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1"; pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive = 1_000..=20_000; pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive = 100..=20_000; -pub const CHANNEL_THREADS_DEFAULT_GUILD_SALT: &str = "channel-threads-guild-v1"; -pub const CHANNEL_THREADS_DEFAULT_USER_SALT: &str = "channel-threads-user-v1"; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(default)] @@ -583,52 +576,6 @@ pub struct DomainMigrationConfigUpdateRequest { pub standalone_forwarding: Option, } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(default)] -pub struct PlutoniumPageConfigResponse { - pub enabled: bool, - pub config_version: u64, - pub rollout_basis_points: u32, - pub rollout_salt: String, - pub included_user_ids: Vec, - pub included_guild_ids: Vec, - pub include_premium_users: bool, - pub excluded_user_ids: Vec, -} - -impl Default for PlutoniumPageConfigResponse { - fn default() -> Self { - Self { - enabled: false, - config_version: 0, - rollout_basis_points: 0, - rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(), - included_user_ids: Vec::new(), - included_guild_ids: Vec::new(), - include_premium_users: false, - excluded_user_ids: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct PlutoniumPageConfigUpdateRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub rollout_basis_points: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub rollout_salt: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub included_user_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub included_guild_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub include_premium_users: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub excluded_user_ids: Option>, -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct CaptchaConfigResponse { @@ -657,62 +604,6 @@ pub struct CaptchaConfigUpdateRequest { pub max_counter: Option, } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(default)] -pub struct ChannelThreadsConfigResponse { - pub enabled: bool, - pub config_version: u64, - pub ever_enabled: bool, - pub guild_basis_points: u32, - pub guild_salt: String, - pub enabled_guild_ids: Vec, - pub disabled_guild_ids: Vec, - pub user_basis_points: u32, - pub user_salt: String, - pub included_user_ids: Vec, - pub excluded_user_ids: Vec, -} - -impl Default for ChannelThreadsConfigResponse { - fn default() -> Self { - Self { - enabled: false, - config_version: 0, - ever_enabled: false, - guild_basis_points: 0, - guild_salt: CHANNEL_THREADS_DEFAULT_GUILD_SALT.to_owned(), - enabled_guild_ids: Vec::new(), - disabled_guild_ids: Vec::new(), - user_basis_points: 0, - user_salt: CHANNEL_THREADS_DEFAULT_USER_SALT.to_owned(), - included_user_ids: Vec::new(), - excluded_user_ids: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct ChannelThreadsConfigUpdateRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub guild_basis_points: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub guild_salt: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled_guild_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled_guild_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_basis_points: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_salt: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub included_user_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub excluded_user_ids: Option>, -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct ExperimentDeliveryConfigResponse { @@ -831,12 +722,8 @@ pub struct InstanceConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] pub domain_migration: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub plutonium_page: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub captcha: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub channel_threads: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub experiment_delivery: Option, #[serde(skip_serializing_if = "Option::is_none")] pub billing: Option, @@ -1137,24 +1024,17 @@ mod tests { .expect("admin schema"); let domain_migration = serde_json::from_value::(json!({})) .expect("default domain migration config"); - let plutonium_page = serde_json::from_value::(json!({})) - .expect("default plutonium page config"); let captcha = serde_json::from_value::(json!({})) .expect("default captcha config"); let delivery = serde_json::from_value::(json!({})) .expect("default delivery config"); let domain_migration = serde_json::to_value(domain_migration).expect("serializable domain migration config"); - let plutonium_page = - serde_json::to_value(plutonium_page).expect("serializable plutonium page config"); let captcha = serde_json::to_value(captcha).expect("serializable captcha config"); let delivery = serde_json::to_value(delivery).expect("serializable delivery config"); let generated_domain_migration: generated_types::DomainMigrationConfigResponse = serde_json::from_value(domain_migration.clone()) .expect("generated domain migration config contract"); - let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse = - serde_json::from_value(plutonium_page.clone()) - .expect("generated plutonium page config contract"); let generated_captcha: generated_types::CaptchaConfigResponse = serde_json::from_value(captcha.clone()).expect("generated captcha config contract"); let generated_delivery: generated_types::ExperimentDeliveryConfigResponse = @@ -1164,11 +1044,6 @@ mod tests { .expect("serializable generated domain migration config"), domain_migration ); - assert_eq!( - serde_json::to_value(generated_plutonium_page) - .expect("serializable generated plutonium page config"), - plutonium_page - ); assert_eq!( serde_json::to_value(generated_captcha).expect("serializable generated captcha config"), captcha @@ -1180,7 +1055,6 @@ mod tests { ); for (name, value) in [ ("DomainMigrationConfigResponse", domain_migration), - ("PlutoniumPageConfigResponse", plutonium_page), ("CaptchaConfigResponse", captcha), ("ExperimentDeliveryConfigResponse", delivery), ] { @@ -1215,25 +1089,4 @@ mod tests { json!({}) ); } - - #[test] - fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() { - let update = PlutoniumPageConfigUpdateRequest { - included_user_ids: Some(Vec::new()), - excluded_user_ids: Some(Vec::new()), - ..Default::default() - }; - let value = serde_json::to_value(update).expect("serializable update"); - serde_json::from_value::(value.clone()) - .expect("generated update contract"); - assert_eq!( - value, - json!({"included_user_ids": [], "excluded_user_ids": []}) - ); - assert_eq!( - serde_json::to_value(PlutoniumPageConfigUpdateRequest::default()) - .expect("serializable update"), - json!({}) - ); - } } diff --git a/fluxer_admin/src/routes/guild_tabs.rs b/fluxer_admin/src/routes/guild_tabs.rs index d8fcdca7a..61e5e18cf 100644 --- a/fluxer_admin/src/routes/guild_tabs.rs +++ b/fluxer_admin/src/routes/guild_tabs.rs @@ -106,20 +106,11 @@ pub async fn render( .map(|response| response.threads) .map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads")) .unwrap_or_default(); - let threads_enabled = client - .get_instance_config() - .await - .map(|instance| instance.channel_threads.enabled) - .map_err( - |error| tracing::warn!(%error, "admin API request failed: get instance config"), - ) - .unwrap_or(false); Some(tabs::threads::threads_tab( config, &guild, &threads, acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL), - threads_enabled, csrf_token, )) } diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index e8fb4d9bb..8311c88ae 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -7,20 +7,19 @@ use crate::{ AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, - CaptchaConfigUpdateRequest, ChannelThreadsConfigUpdateRequest, - CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest, - EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest, - GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, - InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest, - InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest, - InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest, - InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest, - InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest, - InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, - InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, - LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, - PlutoniumPageConfigUpdateRequest, PremiumMode, PushRelayConfigUpdateRequest, - RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope, + CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest, + DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS, + ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest, + GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest, + InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest, + InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest, + InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest, + InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest, + InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, + InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, + InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, + LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode, + SsoConfigUpdateRequest, VoiceE2eeScope, }, }, config::AdminConfig, @@ -221,19 +220,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, - "update_plutonium_page" => match build_plutonium_page_update(&form) { - Ok(update) => instance_config_result(client.update_instance_config(&update).await), - Err(message) => FlashData::error(message), - }, "update_captcha" => match build_captcha_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, - "update_channel_threads" => instance_config_result( - client - .update_instance_config(&build_channel_threads_update(&form)) - .await, - ), "update_experiment_delivery" => match build_experiment_delivery_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), @@ -619,41 +609,6 @@ fn build_domain_migration_update( }) } -fn build_plutonium_page_update( - form: &MultiValueForm, -) -> Result { - Ok(InstanceConfigUpdateRequest { - plutonium_page: Some(PlutoniumPageConfigUpdateRequest { - enabled: Some(form.bool_value("plutonium_page_enabled")), - rollout_basis_points: parse_form_number( - form, - "plutonium_page_rollout_basis_points", - "Rollout basis points", - 0, - EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, - )?, - rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?, - included_user_ids: Some(parse_experiment_user_ids( - form.first("plutonium_page_included_user_ids") - .unwrap_or_default(), - "Included user IDs", - )?), - included_guild_ids: Some(parse_experiment_user_ids( - form.first("plutonium_page_included_guild_ids") - .unwrap_or_default(), - "Included guild IDs", - )?), - include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")), - excluded_user_ids: Some(parse_experiment_user_ids( - form.first("plutonium_page_excluded_user_ids") - .unwrap_or_default(), - "Excluded user IDs", - )?), - }), - ..Default::default() - }) -} - fn build_captcha_update(form: &MultiValueForm) -> Result { Ok(InstanceConfigUpdateRequest { captcha: Some(CaptchaConfigUpdateRequest { @@ -677,28 +632,6 @@ fn build_captcha_update(form: &MultiValueForm) -> Result InstanceConfigUpdateRequest { - let channel_threads = if form.bool_value("channel_threads_everyone") { - ChannelThreadsConfigUpdateRequest { - enabled: Some(true), - guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX), - user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX), - disabled_guild_ids: Some(Vec::new()), - excluded_user_ids: Some(Vec::new()), - ..Default::default() - } - } else { - ChannelThreadsConfigUpdateRequest { - enabled: Some(false), - ..Default::default() - } - }; - InstanceConfigUpdateRequest { - channel_threads: Some(channel_threads), - ..Default::default() - } -} - fn build_experiment_delivery_update( form: &MultiValueForm, ) -> Result { @@ -1464,30 +1397,6 @@ mod tests { } } - #[test] - fn build_channel_threads_update_turns_threads_on_for_everyone() { - let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true"); - assert_eq!( - serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"), - serde_json::json!({"channel_threads": { - "enabled": true, - "guild_basis_points": 10000, - "user_basis_points": 10000, - "disabled_guild_ids": [], - "excluded_user_ids": [], - }}) - ); - } - - #[test] - fn build_channel_threads_update_only_turns_threads_off_when_unchecked() { - let form = MultiValueForm::parse(b"_csrf=token"); - assert_eq!( - serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"), - serde_json::json!({"channel_threads": {"enabled": false}}) - ); - } - #[test] fn build_push_relay_update_reads_the_consent_checkbox() { let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token")); @@ -1568,72 +1477,6 @@ mod tests { ); } - #[test] - fn build_plutonium_page_update_reads_the_rollout_fields() { - let form = MultiValueForm::parse( - b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true", - ); - let update = build_plutonium_page_update(&form) - .expect("valid form") - .plutonium_page - .expect("plutonium page update"); - assert_eq!(update.enabled, Some(true)); - assert_eq!(update.rollout_basis_points, Some(500)); - assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned())); - assert_eq!(update.include_premium_users, Some(true)); - assert_eq!( - update.included_guild_ids, - Some(vec![ - "1500000000000000005".to_owned(), - "1500000000000000006".to_owned() - ]) - ); - assert_eq!( - update.included_user_ids, - Some(vec!["1500000000000000001".to_owned()]) - ); - assert_eq!( - update.excluded_user_ids, - Some(vec!["1500000000000000002".to_owned()]) - ); - } - - #[test] - fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() { - let form = MultiValueForm::parse(b"_csrf=token"); - let request = build_plutonium_page_update(&form).expect("valid form"); - assert_eq!( - serde_json::to_value(request).expect("serializable update"), - serde_json::json!({"plutonium_page": { - "enabled": false, - "included_user_ids": [], - "included_guild_ids": [], - "include_premium_users": false, - "excluded_user_ids": [], - }}) - ); - } - - #[test] - fn build_plutonium_page_update_rejects_invalid_rollout_fields() { - for (form, message) in [ - ( - "plutonium_page_rollout_basis_points=10001", - "Rollout basis points must be a whole number between 0 and 10000", - ), - ( - "plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild", - "Included guild IDs entry 2 must contain 1 to 20 decimal digits", - ), - ] { - let form = MultiValueForm::parse(form.as_bytes()); - assert_eq!( - build_plutonium_page_update(&form).expect_err("invalid field"), - message - ); - } - } - #[test] fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() { let form = MultiValueForm::parse(b"_csrf=token"); diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs index 2769c0992..2d700acf5 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs @@ -17,24 +17,21 @@ pub fn threads_tab( guild: &GuildInfo, threads: &[GuildThreadItem], can_delete: bool, - can_reindex: bool, csrf_token: &str, ) -> Markup { let base = &config.base_path; html! { - @if can_reindex { - (card_with_header("Thread search index", html! { - form method="post" - action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"} - class="w-full" { - (csrf_input(csrf_token)) - input type="hidden" name="index_type" value="threads"; - (form_actions(html! { - (submit_button("Refresh threads")) - })) - } - })) - } + (card_with_header("Thread search index", html! { + form method="post" + action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"} + class="w-full" { + (csrf_input(csrf_token)) + input type="hidden" name="index_type" value="threads"; + (form_actions(html! { + (submit_button("Refresh threads")) + })) + } + })) (card_with_header( &format!("Threads ({})", threads.len()), html! { diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index 4d67ff7ed..4ff690a78 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -4,13 +4,12 @@ use crate::{ api::types::{ AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse, - ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, + DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, - LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse, - PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse, - SsoConfigResponse, TagStyle, + LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse, + RegistrationUrlResponse, SsoConfigResponse, TagStyle, }, config::AdminConfig, middleware::auth::AuthContext, @@ -193,10 +192,6 @@ pub fn instance_config_page( @if !instance_config.self_hosted { (domain_migration_section(base, csrf_token, &instance_config.domain_migration)) } - (channel_threads_section(base, csrf_token, &instance_config.channel_threads)) - @if !instance_config.self_hosted { - (plutonium_page_section(base, csrf_token, &instance_config.plutonium_page)) - } (experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery)) @if let Some(limit_config) = limit_config { (limit_config_section(base, limit_config)) @@ -1281,147 +1276,6 @@ fn domain_migration_section( ) } -fn plutonium_page_section( - base: &str, - csrf_token: &str, - plutonium_page: &PlutoniumPageConfigResponse, -) -> Markup { - let status = if plutonium_page.enabled { - ("Live", BadgeVariant::Success) - } else { - ("Inert", BadgeVariant::Default) - }; - let included_user_ids = plutonium_page.included_user_ids.join("\n"); - let excluded_user_ids = plutonium_page.excluded_user_ids.join("\n"); - section_card_with_description( - "Plutonium page", - "Replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable \ - in chat, and uses a minimal gift purchase modal.", - html! { - form method="post" action={(base) "/instance-config?action=update_plutonium_page"} { - (csrf_input(csrf_token)) - div class="space-y-6" { - div class="flex flex-wrap items-center gap-2" { - h3 class="text-sm font-semibold text-neutral-900" { "Master switch" } - (badge(status.0, status.1)) - span class="text-xs text-neutral-500" { - "Config version " (plutonium_page.config_version) - } - } - (checkbox( - "plutonium_page_enabled", - "true", - "Serve the Plutonium page to the selected users", - plutonium_page.enabled, - true, - )) - p class="text-xs text-neutral-500" { - "Off is the safe state and the kill switch. With this unchecked every \ - client keeps the Plutonium settings tab, so the rollout and targeting \ - fields below have no effect at all." - } - - h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } - (number_field( - "plutonium_page_rollout_basis_points", - "Rollout (basis points)", - &plutonium_page.rollout_basis_points.to_string(), - Some(0), Some(10000), "1", - Some("Share of users bucketed into the Plutonium page, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."), - )) - div class="flex flex-col gap-2" { - (text_input( - "plutonium_page_rollout_salt", - "Rollout Salt", - &plutonium_page.rollout_salt, - PLUTONIUM_PAGE_DEFAULT_SALT, - )) - p class="text-xs text-neutral-500" { - "Seeds the bucketing hash. Changing it reshuffles which users fall \ - inside the percentage above. Leave it alone to keep the current \ - cohort stable." - } - } - div class="flex flex-col gap-2" { - (textarea_input( - "plutonium_page_included_user_ids", - "Always-on User IDs", - "1500000000000000001\n1500000000000000002", - &included_user_ids, - 4, - false, - )) - (entry_count_hint( - plutonium_page.included_user_ids.len(), - EXPERIMENT_MAX_TARGETED_USERS, - )) - p class="text-xs text-neutral-500" { - "One snowflake per line, or comma separated. These users are targeted \ - regardless of the percentage above. IDs must contain 1 to 20 decimal \ - digits. Invalid entries prevent the save. Blank entries and duplicate \ - IDs are ignored." - } - } - div class="flex flex-col gap-2" { - (checkbox( - "plutonium_page_include_premium_users", - "true", - "Include premium users", - plutonium_page.include_premium_users, - true, - )) - p class="text-xs text-neutral-500" { - "Includes every account with active premium perks, regardless of the \ - percentage above. The never-on list still wins." - } - } - div class="flex flex-col gap-2" { - (textarea_input( - "plutonium_page_included_guild_ids", - "Always-on Guild IDs", - "1500000000000000005\n1500000000000000006", - &plutonium_page.included_guild_ids.join("\n"), - 4, - false, - )) - (entry_count_hint( - plutonium_page.included_guild_ids.len(), - EXPERIMENT_MAX_TARGETED_USERS, - )) - p class="text-xs text-neutral-500" { - "Same format, with guild IDs. Every member of a listed guild is \ - included regardless of the percentage above, unless the user is \ - in the never-on list." - } - } - div class="flex flex-col gap-2" { - (textarea_input( - "plutonium_page_excluded_user_ids", - "Never-on User IDs", - "1500000000000000003\n1500000000000000004", - &excluded_user_ids, - 4, - false, - )) - (entry_count_hint( - plutonium_page.excluded_user_ids.len(), - EXPERIMENT_MAX_TARGETED_USERS, - )) - p class="text-xs text-neutral-500" { - "Same format. Exclusion wins over both the always-on list and the \ - percentage." - } - } - - (form_actions(html! { - (submit_button("Save Plutonium Page Configuration")) - })) - } - } - }, - ) -} - fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 { 0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0 } @@ -1489,48 +1343,6 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse ) } -fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool { - channel_threads.enabled - && channel_threads.guild_basis_points >= 10_000 - && channel_threads.user_basis_points >= 10_000 - && channel_threads.disabled_guild_ids.is_empty() - && channel_threads.excluded_user_ids.is_empty() -} - -fn channel_threads_section( - base: &str, - csrf_token: &str, - channel_threads: &ChannelThreadsConfigResponse, -) -> Markup { - let everyone = channel_threads_available_to_everyone(channel_threads); - section_card_with_description( - "Threads and forums", - "Threads, forum channels and media channels in every community.", - html! { - form method="post" action={(base) "/instance-config?action=update_channel_threads"} { - (csrf_input(csrf_token)) - div class="space-y-6" { - (checkbox( - "channel_threads_everyone", - "true", - "Available to everyone", - everyone, - true, - )) - @if channel_threads.enabled && !everyone { - p class="text-xs text-neutral-500" { - "Currently on for part of this instance. Saving applies the setting above to everyone." - } - } - (form_actions(html! { - (submit_button("Save")) - })) - } - } - }, - ) -} - fn experiment_delivery_section( base: &str, csrf_token: &str, @@ -2274,66 +2086,6 @@ mod tests { assert!(!markup.contains("at the cap")); } - #[test] - fn channel_threads_section_is_a_single_everyone_toggle() { - let everyone = ChannelThreadsConfigResponse { - enabled: true, - guild_basis_points: 10_000, - user_basis_points: 10_000, - ..ChannelThreadsConfigResponse::default() - }; - let on = channel_threads_section("/admin", "csrf", &everyone).into_string(); - assert!(on.contains("action=update_channel_threads")); - assert!(on.contains("name=\"channel_threads_everyone\"")); - assert!(on.contains("checked")); - assert!(!on.contains("basis_points")); - assert!(!on.contains("part of this instance")); - - let partial = ChannelThreadsConfigResponse { - enabled: true, - enabled_guild_ids: vec!["1600000000000000001".to_owned()], - user_basis_points: 10_000, - ..ChannelThreadsConfigResponse::default() - }; - let partial = channel_threads_section("/admin", "csrf", &partial).into_string(); - assert!(!partial.contains("checked")); - assert!(partial.contains("part of this instance")); - - let off = - channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default()) - .into_string(); - assert!(!off.contains("checked")); - assert!(!off.contains("part of this instance")); - } - - #[test] - fn plutonium_page_section_shows_the_rollout_and_list_counts() { - let plutonium_page = PlutoniumPageConfigResponse { - enabled: true, - config_version: 3, - rollout_basis_points: 250, - included_user_ids: vec!["1500000000000000001".to_owned()], - excluded_user_ids: vec![ - "1500000000000000002".to_owned(), - "1500000000000000003".to_owned(), - ], - ..PlutoniumPageConfigResponse::default() - }; - let markup = plutonium_page_section("/admin", "csrf", &plutonium_page).into_string(); - assert!(markup.contains("Plutonium page")); - assert!(markup.contains("action=update_plutonium_page")); - assert!(markup.contains("name=\"plutonium_page_enabled\"")); - assert!(markup.contains("name=\"plutonium_page_rollout_basis_points\"")); - assert!(markup.contains("value=\"250\"")); - assert!(markup.contains("name=\"plutonium_page_include_premium_users\"")); - assert!(markup.contains("name=\"plutonium_page_included_guild_ids\"")); - assert!(markup.contains("Config version 3")); - assert!(markup.contains("1 of 1000 stored")); - assert!(markup.contains("2 of 1000 stored")); - assert!(!markup.contains("anonymous_rollout_basis_points")); - assert!(!markup.contains("standalone_forwarding")); - } - #[test] fn push_relay_section_shows_the_consent_toggle() { let accepted = PushRelayConfigResponse { diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index b525d3b58..78161bc74 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -416,36 +416,12 @@ fn deserialize_instance_config_response_with_unknown_keys() { "anonymous_rollout_basis_points": 100, "standalone_forwarding": true }, - "plutonium_page": { - "enabled": true, - "config_version": 3, - "rollout_basis_points": 500, - "rollout_salt": "plutonium-page-v1", - "included_user_ids": ["1500000000000000001"], - "excluded_user_ids": ["1500000000000000002"], - "included_guild_ids": ["1500000000000000005"], - "include_premium_users": true, - "future_plutonium_page_knob": true - }, "captcha": { "enabled": true, "cost": 5000, "max_counter": 1000, "future_captcha_knob": 1 }, - "channel_threads": { - "enabled": true, - "config_version": 3, - "ever_enabled": true, - "guild_basis_points": 0, - "guild_salt": "channel-threads-guild-v1", - "enabled_guild_ids": ["1600000000000000001"], - "disabled_guild_ids": [], - "user_basis_points": 10000, - "user_salt": "channel-threads-user-v1", - "included_user_ids": [], - "excluded_user_ids": [] - }, "experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15}, "registration": { "mode": "open", @@ -597,20 +573,9 @@ fn deserialize_instance_config_response_with_unknown_keys() { assert_eq!(resp.domain_migration.included_user_ids.len(), 1); assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100); assert!(resp.domain_migration.standalone_forwarding); - assert!(resp.plutonium_page.enabled); - assert_eq!(resp.plutonium_page.config_version, 3); - assert_eq!(resp.plutonium_page.rollout_basis_points, 500); - assert_eq!(*resp.plutonium_page.rollout_salt, "plutonium-page-v1"); - assert_eq!(resp.plutonium_page.included_user_ids.len(), 1); - assert_eq!(resp.plutonium_page.excluded_user_ids.len(), 1); - assert_eq!(resp.plutonium_page.included_guild_ids.len(), 1); - assert!(resp.plutonium_page.include_premium_users); assert!(resp.push_relay.relay_consent_accepted); assert!(resp.captcha.enabled); assert_eq!(resp.captcha.max_counter, 1000); - assert!(resp.channel_threads.enabled); - assert_eq!(resp.channel_threads.config_version, 3); - assert_eq!(resp.channel_threads.enabled_guild_ids.len(), 1); assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300); assert!(resp.policy.single_community_guild_id.is_none()); assert_eq!(resp.policy.services.gif_enabled, Some(true)); @@ -678,63 +643,6 @@ fn deserialize_instance_config_response_with_unknown_keys() { ); } -#[test] -fn deserialize_channel_threads_config() { - let config: types::ChannelThreadsConfigResponse = serde_json::from_str( - r#"{ - "enabled": true, - "config_version": 12, - "ever_enabled": true, - "guild_basis_points": 50, - "guild_salt": "channel-threads-guild-v2", - "enabled_guild_ids": ["1600000000000000001"], - "disabled_guild_ids": ["1600000000000000002", "1600000000000000003"], - "user_basis_points": 10000, - "user_salt": "channel-threads-user-v1", - "included_user_ids": [], - "excluded_user_ids": ["1500000000000000001"], - "future_threads_knob": 1 - }"#, - ) - .expect("a channel threads config must deserialize"); - - assert!(config.enabled); - assert!(config.ever_enabled); - assert_eq!(config.config_version, 12); - assert_eq!(config.guild_basis_points, 50); - assert_eq!(config.guild_salt, "channel-threads-guild-v2"); - assert_eq!(config.enabled_guild_ids, vec!["1600000000000000001"]); - assert_eq!(config.disabled_guild_ids.len(), 2); - assert_eq!(config.user_basis_points, 10000); - assert_eq!(config.excluded_user_ids, vec!["1500000000000000001"]); - - let absent: types::ChannelThreadsConfigResponse = - serde_json::from_str("{}").expect("an api without the experiment still deserializes"); - assert!(!absent.enabled); - assert!(!absent.ever_enabled); - assert_eq!(absent.guild_salt, types::CHANNEL_THREADS_DEFAULT_GUILD_SALT); - assert_eq!(absent.user_salt, types::CHANNEL_THREADS_DEFAULT_USER_SALT); -} - -#[test] -fn serialize_channel_threads_update_never_sends_server_owned_fields() { - let update = types::InstanceConfigUpdateRequest { - channel_threads: Some(types::ChannelThreadsConfigUpdateRequest { - enabled: Some(true), - enabled_guild_ids: Some(vec!["1600000000000000001".to_owned()]), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&update).unwrap(), - serde_json::json!({"channel_threads": { - "enabled": true, - "enabled_guild_ids": ["1600000000000000001"], - }}) - ); -} - #[test] fn deserialize_push_relay_config() { let accepted: types::PushRelayConfigResponse = serde_json::from_str( diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index 3c2be9c57..aa6f37ed9 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -469,8 +469,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() { "/instance-config?action=update_gateway_rollout", "/instance-config?action=update_sso", "/instance-config?action=update_domain_migration", - "/instance-config?action=update_channel_threads", - "/instance-config?action=update_plutonium_page", "/instance-config?action=update_experiment_delivery", ][..], ), @@ -487,47 +485,13 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() { } #[tokio::test] -async fn channel_threads_section_renders_and_saves_through_htmx_toasts() { +async fn instance_config_has_no_threads_or_plutonium_page_rollout_sections() { let app = setup().await; - let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await; + let body = get(&app, "/instance-config", &[]).await; assert_full_layout(&body); - assert!(body.contains("Threads and forums"), "{body}"); - assert!(body.contains("Available to everyone"), "{body}"); - assert!( - body.contains(r#"name="channel_threads_everyone""#), - "{body}" - ); - assert!( - !body.contains("channel_threads_guild_basis_points"), - "{body}" - ); - let csrf_token = csrf_cookie(&headers) - .unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{body}")); - let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); - let htmx_headers = [ - ("HX-Request", "true"), - ("HX-Target", "flash-container"), - ("Cookie", cookie.as_str()), - ]; - - for form in [ - format!("_csrf={csrf_token}&channel_threads_everyone=true"), - format!("_csrf={csrf_token}"), - ] { - let (status, response_headers, response_body) = post_form_with_headers( - &app, - "/instance-config?action=update_channel_threads", - &htmx_headers, - &form, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}"); - let toast = response_headers - .get("X-Fluxer-Admin-Toast") - .and_then(|value| value.to_str().ok()) - .unwrap_or_else(|| panic!("missing toast header\n{response_body}")); - assert!(toast.contains("Instance config updated"), "{toast}"); - } + assert!(!body.contains("Threads and forums"), "{body}"); + assert!(!body.contains("update_channel_threads"), "{body}"); + assert!(!body.contains("update_plutonium_page"), "{body}"); } #[tokio::test] @@ -1238,27 +1202,6 @@ fn instance_config() -> Value { "anonymous_rollout_basis_points": 0, "standalone_forwarding": false }, - "channel_threads": { - "enabled": false, - "config_version": 3, - "ever_enabled": true, - "guild_basis_points": 0, - "guild_salt": "channel-threads-guild-v1", - "enabled_guild_ids": ["1600000000000000001"], - "disabled_guild_ids": [], - "user_basis_points": 10000, - "user_salt": "channel-threads-user-v1", - "included_user_ids": [], - "excluded_user_ids": ["1500000000000000009"] - }, - "plutonium_page": { - "enabled": false, - "config_version": 0, - "rollout_basis_points": 0, - "rollout_salt": "plutonium-page-v1", - "included_user_ids": [], - "excluded_user_ids": [] - }, "experiment_delivery": { "poll_interval_seconds": 300, "poll_jitter_percent": 15 diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index 3a0d1726d..1581dab58 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -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 { gatewayRollout, pushRelay, domainMigration, - plutoniumPage, captcha, - channelThreads, experimentDelivery, registrationConfig, registrationUrls, @@ -90,9 +76,7 @@ async function buildInstanceConfigResponse(): Promise { 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 { 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, }, }); diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts b/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts index 38ef7bede..87e8e141f 100644 --- a/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts +++ b/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts @@ -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 { const created = await createBuilder(harness, admin.token) @@ -82,26 +80,6 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray { - afterEach(() => { + beforeEach(() => { syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({})); }); diff --git a/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts b/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts index 6b0b5e8d7..e45ba0e9c 100644 --- a/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts +++ b/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts @@ -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 = { }; export async function setChannelThreadsConfig(patch: Partial): Promise { - 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 ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + pinChannelThreadsConfigForTesting(DEFAULT_CHANNEL_THREADS_CONFIG); clearChannelThreadsTaintCacheForTesting(); } diff --git a/fluxer_api/src/api/channel/threads/ThreadJobs.ts b/fluxer_api/src/api/channel/threads/ThreadJobs.ts index c9cc00070..03411dbda 100644 --- a/fluxer_api/src/api/channel/threads/ThreadJobs.ts +++ b/fluxer_api/src/api/channel/threads/ThreadJobs.ts @@ -28,14 +28,6 @@ export async function enqueueRemoveThreadMemberships(guildId: GuildID, userId: U ); } -export async function enqueueRebuildThreadAutoArchiveQueue(guildId: string): Promise { - await (await workerService()).addJob( - 'rebuildThreadAutoArchiveQueue', - {guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version}, - {jobKey: `rebuild-thread-archive-queue-${guildId}`}, - ); -} - export function enqueueRepairThreadIndexes(threadIds: Array): void { if (threadIds.length === 0) return; const window = Math.floor(Date.now() / REPAIR_DEBOUNCE_MS); diff --git a/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts index f60b70d83..3a35d6656 100644 --- a/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts +++ b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts @@ -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')); diff --git a/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts index 79a04fab9..64bf7f172 100644 --- a/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts +++ b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts @@ -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 { - await getInstanceConfigRepository().updateChannelThreadsConfig((current) => - applyChannelThreadsConfigUpdate(current, update), - ); + async function setConfig(update: Partial): Promise { + await setChannelThreadsConfig({...getCompiledChannelThreadsConfig().config, ...update}); } async function snapshot( diff --git a/fluxer_api/src/api/experiment/ChannelThreadsGate.ts b/fluxer_api/src/api/experiment/ChannelThreadsGate.ts index 16623d7d4..95e5bcfc7 100644 --- a/fluxer_api/src/api/experiment/ChannelThreadsGate.ts +++ b/fluxer_api/src/api/experiment/ChannelThreadsGate.ts @@ -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(); -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; } diff --git a/fluxer_api/src/api/experiment/ExperimentController.ts b/fluxer_api/src/api/experiment/ExperimentController.ts index ea41621c4..23a178879 100644 --- a/fluxer_api/src/api/experiment/ExperimentController.ts +++ b/fluxer_api/src/api/experiment/ExperimentController.ts @@ -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); diff --git a/fluxer_api/src/api/experiment/ExperimentTargeting.ts b/fluxer_api/src/api/experiment/ExperimentTargeting.ts index f6aded31f..45cb6632b 100644 --- a/fluxer_api/src/api/experiment/ExperimentTargeting.ts +++ b/fluxer_api/src/api/experiment/ExperimentTargeting.ts @@ -13,9 +13,9 @@ const NO_GUILDS: ReadonlySet = new Set(); export async function resolveExperimentTargeting( user: User, - configs: ReadonlyArray, + config: TargetableExperimentConfig, ): Promise { - 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; diff --git a/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts b/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts index 83b1fab29..51c34f57b 100644 --- a/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts +++ b/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts @@ -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', () => { diff --git a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts index 9befbec71..d10164642 100644 --- a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts +++ b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts @@ -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(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(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(harness, targeted.token) - .get(ENDPOINT) - .execute(); - expect(targetedBody.assignments.plutonium_page).toEqual({enabled: true}); - - const untargetedBody = await createBuilder(harness, untargeted.token) - .get(ENDPOINT) - .execute(); - expect(untargetedBody.assignments.plutonium_page).toEqual({enabled: false}); - - const excludedBody = await createBuilder(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(harness, excluded.token) - .get(ENDPOINT) - .execute(); - expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false}); - - const otherBody = await createBuilder(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(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(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(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; include_premium_users: boolean}> - >(harness, admin.token) + const body = await createBuilder<{ + domain_migration: {included_guild_ids: Array; 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>(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(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(harness, account.token) - .get(ENDPOINT) - .executeWithResponse(); - - await getInstanceConfigRepository().updateChannelThreadsConfig((current) => - applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: ['1']}), - ); - const after = await createBuilder(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(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) => - 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(harness, admin.token).get(ENDPOINT).execute(); - expect(readChannelThreadsAssignment(body)).toBeNull(); + expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 7}); }); }); diff --git a/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts b/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts index 9e0b555dd..01f35b7d2 100644 --- a/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts +++ b/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts @@ -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 = new Set(); const CAPABLE: ReadonlySet = 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); - const markers = new Map([ - [ - '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', - ]); - }); }); diff --git a/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts b/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts index cc6870823..924143d6b 100644 --- a/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts +++ b/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts @@ -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 = { const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}); -async function setConfig(patch: Partial): Promise { - 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(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(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); diff --git a/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts b/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts index db20b6e0d..b25276462 100644 --- a/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts +++ b/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts @@ -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): Promise { - 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); }); diff --git a/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts index b946e24b2..0e19be36b 100644 --- a/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts +++ b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts @@ -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]); - }); }); diff --git a/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts index 09988cd83..4c1f5e8cb 100644 --- a/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts +++ b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts @@ -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 { 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 { - 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; - } } diff --git a/fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts b/fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts deleted file mode 100644 index bb1d00ee6..000000000 --- a/fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts +++ /dev/null @@ -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 { - 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(); - } -} diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts index d2e2a3eaf..c06acd394 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts @@ -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); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index c56f8ca7a..fe9fbae97 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -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 { - const raw = await this.getConfig(PLUTONIUM_PAGE_CONFIG_KEY); - return parseStoredPlutoniumPageConfig(raw); - } - - async setPlutoniumPageConfig(config: PlutoniumPageConfig): Promise { - await this.updatePlutoniumPageConfig(() => config); - } - - updatePlutoniumPageConfig( - update: (current: PlutoniumPageConfig) => PlutoniumPageConfig, - ): Promise { - return this.updateStoredConfig(PLUTONIUM_PAGE_CONFIG_KEY, (raw) => - validateStoredConfig(PlutoniumPageConfigSchema, update(parseStoredPlutoniumPageConfig(raw)), 'plutonium page'), - ); - } - async getCaptchaConfig(): Promise { 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 { - 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 { const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY); return parseStoredExperimentDeliveryConfig(raw); diff --git a/fluxer_api/src/api/middleware/ServiceSingletons.ts b/fluxer_api/src/api/middleware/ServiceSingletons.ts index 6d7572616..d3090212e 100644 --- a/fluxer_api/src/api/middleware/ServiceSingletons.ts +++ b/fluxer_api/src/api/middleware/ServiceSingletons.ts @@ -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; diff --git a/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts b/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts index 9ade603e4..f5ab4605b 100644 --- a/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts +++ b/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts @@ -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)); }); }); diff --git a/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts b/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts index bcd7f7470..514674895 100644 --- a/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts +++ b/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts @@ -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(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(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}`}, ); diff --git a/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts b/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts index 1fdcace9a..042485600 100644 --- a/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts +++ b/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts @@ -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): Promise { - 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); } diff --git a/fluxer_api/src/api/test/Setup.ts b/fluxer_api/src/api/test/Setup.ts index 4d1d5e2e1..637be1f1a 100644 --- a/fluxer_api/src/api/test/Setup.ts +++ b/fluxer_api/src/api/test/Setup.ts @@ -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(); }); diff --git a/fluxer_api/src/api/user/services/UserContentService.test.ts b/fluxer_api/src/api/user/services/UserContentService.test.ts index 939671d2a..70c23bb5c 100644 --- a/fluxer_api/src/api/user/services/UserContentService.test.ts +++ b/fluxer_api/src/api/user/services/UserContentService.test.ts @@ -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) : {})); }); diff --git a/fluxer_api/src/api/worker/WorkerLaneConfig.ts b/fluxer_api/src/api/worker/WorkerLaneConfig.ts index d2079f250..f0c3e3698 100644 --- a/fluxer_api/src/api/worker/WorkerLaneConfig.ts +++ b/fluxer_api/src/api/worker/WorkerLaneConfig.ts @@ -87,7 +87,6 @@ const LANE_CONFIG = { 'refreshSearchIndex', 'seedThreadPermissions', 'archiveInactiveThreads', - 'rebuildThreadAutoArchiveQueue', 'repairThreadIndexes', 'deleteChannelThreads', 'removeThreadMembershipsForGuildMember', diff --git a/fluxer_api/src/api/worker/WorkerTaskRegistry.ts b/fluxer_api/src/api/worker/WorkerTaskRegistry.ts index 30f80e6df..fd8be9de2 100644 --- a/fluxer_api/src/api/worker/WorkerTaskRegistry.ts +++ b/fluxer_api/src/api/worker/WorkerTaskRegistry.ts @@ -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 = { applicationProcessDeletion, archiveInactiveThreads, deleteChannelThreads, - rebuildThreadAutoArchiveQueue, removeThreadMembershipsForGuildMember, repairThreadIndexes, syncThreadSearchDocument, diff --git a/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts b/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts index 9849a6b4a..e539ac0d1 100644 --- a/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts +++ b/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts @@ -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(); 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 { - const compiled = compileChannelThreadsConfig(config); - return config.enabled_guild_ids.filter((id) => channelThreadsGuildActive(compiled, id)); -} - -export function newlyEnabledThreadGuildIds( - previous: ChannelThreadsConfig, - landed: ChannelThreadsConfig, -): Array { - const before = new Set(enabledThreadGuildIds(previous)); - return enabledThreadGuildIds(landed).filter((id) => !before.has(id)); -} - -export async function enqueueThreadPermissionSeeds( - threads: IThreadRepository, - config: ChannelThreadsConfig, -): Promise { - 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 { await getWorkerService().addJob( 'seedThreadPermissions', diff --git a/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts b/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts index 39471d3d6..ec5a08d05 100644 --- a/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts +++ b/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts @@ -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(); diff --git a/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts b/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts index c26c842e9..e822d7bc5 100644 --- a/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts +++ b/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts @@ -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([]); diff --git a/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts b/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts index f6edc11eb..e978356cb 100644 --- a/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts +++ b/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts @@ -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 | 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( GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}), {guild_id: guildId}, diff --git a/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts b/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts index 12eb20b4b..f0eaa76f5 100644 --- a/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts +++ b/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts @@ -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); diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index b9942d6cc..7aa8d0021 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -36,7 +36,6 @@ Missing settings use the defaults documented below. Invalid stored configuration | gateway_rollout | [Gateway rollout configuration](#gateway-rollout-configuration-object) object | Gateway admission and dispatch tuning | | push_relay | [push relay configuration](#push-relay-configuration-object) object | Operator consent to the Fluxer-run push relay | | domain_migration | [domain migration configuration](#domain-migration-configuration-object) object | Web domain migration rollout | -| plutonium_page | [Plutonium page configuration](#plutonium-page-configuration-object) object | Plutonium page rollout | | captcha | [captcha configuration](#captcha-configuration-object) object | ALTCHA proof-of-work captcha settings | | experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on | | registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations | @@ -160,27 +159,6 @@ Only the official web client acts on this configuration. On any other instance i Setting `enabled` to false stops new migrations and also stops forwarding from the legacy origin for clients that already moved. Excluding an account only stops a migration that has not started yet. ::: -## Plutonium page configuration object - -The instance rollout that replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable in chat, and uses a minimal gift purchase modal. [Experiments](/http-api/experiments/#plutonium-page-assignment-object) defines what a client resolves from it. - -### Structure - -| Field | Type | Description | -| --- | --- | --- | -| enabled | boolean | Whether the rollout runs at all (default false) | -| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request | -| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) | -| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `plutonium-page-v1`) | -| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) | -| included_guild_ids | array[snowflake] | Guilds whose members the rollout always selects, up to 1000 entries (default empty) | -| include_premium_users | boolean | Whether the rollout always selects accounts with active premium (default false) | -| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) | - -Every field is present on read. An absent document or missing field uses the defaults above. - -`excluded_user_ids` wins over every other rule. Otherwise the rollout selects an account in `included_user_ids`, a member of a guild in `included_guild_ids`, or a premium account while `include_premium_users` is true, whatever `rollout_basis_points` says. - ## Captcha configuration object The ALTCHA proof-of-work check that the API issues and verifies itself on gated operations. [CAPTCHA handling](/topics/captcha/) defines the challenge exchange and lists the gated operations. @@ -633,7 +611,6 @@ The body has one optional object for each section. Fluxer leaves an absent secti | gateway_rollout? | object | Any subset of the [Gateway rollout configuration](#gateway-rollout-configuration-object) fields, each bound as documented there | | push_relay? | object | `relay_consent_accepted` from the [push relay configuration](#push-relay-configuration-object) | | domain_migration? | object | Any subset of the [domain migration](#domain-migration-configuration-object) fields | -| plutonium_page? | object | Any subset of the [Plutonium page](#plutonium-page-configuration-object) fields | | captcha? | object | Any subset of the [captcha configuration](#captcha-configuration-object) fields | | experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields | | registration? | object | `mode` and `admin_registration_urls_enabled` | @@ -651,8 +628,6 @@ The body has one optional object for each section. Fluxer leaves an absent secti `domain_migration` takes every [domain migration configuration](#domain-migration-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone. -`plutonium_page` works the same way, over the [Plutonium page configuration](#plutonium-page-configuration-object) fields and its own `config_version`. - `captcha` takes any subset of the [captcha configuration](#captcha-configuration-object) fields, each bound as documented there. Fluxer merges the supplied fields over the stored ones. The section has no `config_version`. `experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one. @@ -693,7 +668,7 @@ On a self-hosted deployment, billing and the `everyone` premium mode exclude eac | 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` | :::caution[Sections are applied one after another] -The order is `gateway_rollout`, `push_relay`, `domain_migration`, `plutonium_page`, `captcha`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, `billing`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written. +The order is `gateway_rollout`, `push_relay`, `domain_migration`, `captcha`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, `billing`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written. ::: ### Side effects diff --git a/fluxer_docs/src/content/docs/admin-api/threads.mdx b/fluxer_docs/src/content/docs/admin-api/threads.mdx index efa8cdfd9..7f9909bef 100644 --- a/fluxer_docs/src/content/docs/admin-api/threads.mdx +++ b/fluxer_docs/src/content/docs/admin-api/threads.mdx @@ -1,47 +1,13 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Admin threads -description: The channel_threads rollout configuration, guild thread lookup, and thread deletion. +description: Guild thread lookup and thread deletion. draft: true --- import RouteHeader from '@/components/RouteHeader.astro'; -The Admin API reads and deletes [threads](/http-api/threads/) whether or not the `channel_threads` experiment is active for their guild. The rollout itself is part of the [instance configuration](/admin-api/instance/#instance-configuration-object) under `channel_threads`. - -## Channel threads configuration object - -The instance rollout of threads, forum channels, and media channels. A guild is enabled by the guild fields, and a user within it by the user fields. - -### Structure - -| Field | Type | Description | -| --- | --- | --- | -| enabled | boolean | Whether the rollout runs at all (default false) | -| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request | -| ever_enabled1 | boolean | Whether `enabled` was ever true | -| guild_basis_points | integer | Share of guilds the rollout selects, in basis points (0-10000, default 0) | -| guild_salt | string | Salt of the guild sampling hash (1-64 printable ASCII characters, default `channel-threads-guild-v1`) | -| enabled_guild_ids | array[snowflake] | Guilds the rollout always selects, up to 1000 entries (default empty) | -| disabled_guild_ids | array[snowflake] | Guilds the rollout never selects, up to 1000 entries (default empty) | -| user_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) | -| user_salt | string | Salt of the account sampling hash (1-64 printable ASCII characters, default `channel-threads-user-v1`) | -| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) | -| excluded_user_ids2 | array[snowflake] | Accounts and bots the rollout never selects, up to 1000 entries (default empty) | - -1 Set by Fluxer on the first write with `enabled` true, never cleared, and never accepted from a request - -2 An excluded moderation bot sees no thread and cannot act on one - -Every field is present on read. An absent document or missing field uses the defaults above. - -`disabled_guild_ids` wins over `enabled_guild_ids`, and `enabled_guild_ids` wins over `guild_basis_points`. The user fields follow the same order. A bot follows the guild fields and `excluded_user_ids` alone. A user also needs a client that declares `channel_threads`. - -A user outside the rollout in an enabled guild sees no thread, forum, or media channel while other members do. Set `user_basis_points` to 10000 before enabling a guild outside staff. - -:::caution[Disabling keeps the stored threads] -Setting `enabled` to false hides every thread, forum, and media channel and stops automatic archiving. Re-enabling shows the stored data again. -::: +The Admin API reads and deletes [threads](/http-api/threads/) in any guild. ## List guild threads diff --git a/fluxer_docs/src/content/docs/gateway/threads.md b/fluxer_docs/src/content/docs/gateway/threads.md index 96122be5a..c2b5fa376 100644 --- a/fluxer_docs/src/content/docs/gateway/threads.md +++ b/fluxer_docs/src/content/docs/gateway/threads.md @@ -7,8 +7,8 @@ draft: true A session receives [threads](/http-api/threads/) through the Dispatch events below. A guild ready object in [Ready](/gateway/events/#ready), [Guild Create](/gateway/events/#guild-create), and [Guild Sync](/gateway/events/#guild-sync) has `threads` only when the session can view threads in that guild. A client treats the presence of that key as the signal that threads, forums, and media channels exist in the guild. -:::note[Threads need the `channel_threads` experiment] -A user session receives thread data only when it sets `CHANNEL_THREADS` in Identify and the experiment is active for the guild and the account. A bot session needs no flag. +:::note[Threads need the `CHANNEL_THREADS` capability] +A user session receives thread data only when it sets `CHANNEL_THREADS` in Identify. A bot session needs no flag. ::: ## Session flag diff --git a/fluxer_docs/src/content/docs/http-api/experiments.mdx b/fluxer_docs/src/content/docs/http-api/experiments.mdx index 9ac89b807..6f4c0a272 100644 --- a/fluxer_docs/src/content/docs/http-api/experiments.mdx +++ b/fluxer_docs/src/content/docs/http-api/experiments.mdx @@ -6,7 +6,7 @@ description: The experiment assignments envelope, the revalidation and polling c import RouteHeader from '@/components/RouteHeader.astro'; -An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `domain_migration` and `plutonium_page`. +An experiment is an instance-wide rollout. For each account, Fluxer works out whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `domain_migration` and `plutonium_page`. Only `domain_migration` is configured by an operator. `plutonium_page` is on for every account. Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran. @@ -53,17 +53,17 @@ Only the official web client acts on this assignment, and only on its legacy ori ## Plutonium page assignment object -One resolution of the instance Plutonium page rollout against one account. This server version writes the key on every response. +The Plutonium page state for one account. This server version writes the key on every response, always with `enabled` true. ### Structure | Field | Type | Description | | --- | --- | --- | -| enabled | boolean | Whether the caller's client shows the full Plutonium page in place of the Plutonium settings tab | +| enabled | boolean | Whether the caller's client shows the full Plutonium page in place of the Plutonium settings tab, always true | -A caller is drawn by the operator's account or guild allowlist, by having premium when the operator includes premium users, or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included. +The rollout is complete and has no operator setting. The key stays for clients that still read it. -The assignment only changes what the client shows. A selected client replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable in chat, and uses a minimal gift purchase modal. The server applies no part of it on its own. +The assignment only changes what the client shows. An enabled client replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable in chat, and uses a minimal gift purchase modal. The server applies no part of it on its own. ## Get experiment assignments diff --git a/fluxer_docs/src/content/docs/http-api/forums.mdx b/fluxer_docs/src/content/docs/http-api/forums.mdx index 2b9ab46b1..f53e86774 100644 --- a/fluxer_docs/src/content/docs/http-api/forums.mdx +++ b/fluxer_docs/src/content/docs/http-api/forums.mdx @@ -9,8 +9,8 @@ import RouteHeader from '@/components/RouteHeader.astro'; A forum channel holds posts, and every post is a public [thread](/http-api/threads/) with its own first message. A media channel is a forum channel whose posts center on attachments. Neither accepts a message of its own. -:::note[Every route needs the `channel_threads` experiment] -A route on this page answers 404 `NOT_FOUND` unless the experiment is active for the guild and the caller. A user session also declares `channel_threads` in the `X-Fluxer-Features` header, and a bot needs no declaration. +:::note[Every route needs the `channel_threads` client capability] +A route on this page answers 404 `NOT_FOUND` to a user session that does not declare `channel_threads` in the `X-Fluxer-Features` header. A bot needs no declaration. ::: ## Forum channel fields diff --git a/fluxer_docs/src/content/docs/http-api/thread-members.mdx b/fluxer_docs/src/content/docs/http-api/thread-members.mdx index 7692900b4..5f2b5fed5 100644 --- a/fluxer_docs/src/content/docs/http-api/thread-members.mdx +++ b/fluxer_docs/src/content/docs/http-api/thread-members.mdx @@ -9,8 +9,8 @@ import RouteHeader from '@/components/RouteHeader.astro'; A thread member records that a user joined a [thread](/http-api/threads/). A thread holds at most 1,000 members, and adding one more returns 400 `MAX_THREAD_MEMBERS`. -:::note[Every route needs the `channel_threads` experiment] -A route on this page answers 404 `NOT_FOUND` unless the experiment is active for the guild and the caller. A user session also declares `channel_threads` in the `X-Fluxer-Features` header, and a bot needs no declaration. +:::note[Every route needs the `channel_threads` client capability] +A route on this page answers 404 `NOT_FOUND` to a user session that does not declare `channel_threads` in the `X-Fluxer-Features` header. A bot needs no declaration. ::: ## Thread member object diff --git a/fluxer_docs/src/content/docs/http-api/threads.mdx b/fluxer_docs/src/content/docs/http-api/threads.mdx index a75e07ecf..095cca131 100644 --- a/fluxer_docs/src/content/docs/http-api/threads.mdx +++ b/fluxer_docs/src/content/docs/http-api/threads.mdx @@ -11,8 +11,8 @@ A thread is a channel inside a guild text, announcement, forum, or media channel [Thread members](/http-api/thread-members/) defines joining, leaving, and member lists. [Forums](/http-api/forums/) defines forum and media channels, where every thread is a post. -:::note[Every route needs the `channel_threads` experiment] -A route on this page answers 404 `NOT_FOUND` unless the experiment is active for the guild and the caller. A user session also declares `channel_threads` in the `X-Fluxer-Features` header, and a bot needs no declaration. +:::note[Every route needs the `channel_threads` client capability] +A route on this page answers 404 `NOT_FOUND` to a user session that does not declare `channel_threads` in the `X-Fluxer-Features` header. A bot needs no declaration. ::: ## Thread object @@ -539,7 +539,7 @@ Sending a message in a thread adds a user sender who is not a member. A mentione | Message search result | threads | The threads that contain a returned message | | Message search result | members | A thread member object for each of those threads the caller joined | | Audit log | threads | The threads referenced by the returned entries | -| Experiment assignments | channel_threads | `active` and `config_version`, present only for an account in the rollout | +| Experiment assignments | channel_threads | `active` and `config_version`, present for every account | The guild audit log records thread creation, update, and deletion as action types 110, 111, and 112. diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 66491fc66..9038c876b 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -12,10 +12,6 @@ import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/Admin import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import {CaptchaConfigResponse, CaptchaConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/CaptchaSchemas'; -import { - ChannelThreadsConfigResponse, - ChannelThreadsConfigUpdateRequest, -} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import { DomainMigrationConfigResponse, DomainMigrationConfigUpdateRequest, @@ -28,10 +24,6 @@ import { InstanceBillingResponse, InstanceBillingUpdateRequest, } from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas'; -import { - PlutoniumPageConfigResponse, - PlutoniumPageConfigUpdateRequest, -} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas'; import {PushRelayConfigResponse, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas'; import { ExperimentDeliveryConfigResponse, @@ -143,7 +135,7 @@ const SearchIndexTypeEnum = createNamedStringLiteralUnion( ['guild_members', 'guild_members', 'Guild member search index'], ['favorite_memes', 'favorite_memes', 'Favourite meme search index'], ['discovery', 'discovery', 'Discovery guild search index'], - ['threads', 'threads', 'Thread search index (channel_threads experiment)'], + ['threads', 'threads', 'Thread search index'], ], 'Type of search index to refresh', ); @@ -676,9 +668,7 @@ export const InstanceConfigResponse = z.object({ gateway_rollout: GatewayRolloutConfigResponse, push_relay: PushRelayConfigResponse, domain_migration: DomainMigrationConfigResponse, - plutonium_page: PlutoniumPageConfigResponse, captcha: CaptchaConfigResponse, - channel_threads: ChannelThreadsConfigResponse, experiment_delivery: ExperimentDeliveryConfigResponse, registration: InstanceRegistrationResponse, self_hosted: z.boolean(), @@ -712,9 +702,7 @@ export const InstanceConfigUpdateRequest = z.object({ gateway_rollout: GatewayRolloutConfigUpdateRequest.nullish(), push_relay: PushRelayConfigUpdateRequest.nullish(), domain_migration: DomainMigrationConfigUpdateRequest.nullish(), - plutonium_page: PlutoniumPageConfigUpdateRequest.nullish(), captcha: CaptchaConfigUpdateRequest.nullish(), - channel_threads: ChannelThreadsConfigUpdateRequest.nullish(), experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(), registration: z .object({ diff --git a/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts b/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts index eaee469ee..cbe280204 100644 --- a/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts +++ b/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts @@ -1,18 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { - applyChannelThreadsConfigUpdate, type ChannelThreadsConfig, ChannelThreadsConfigSchema, - ChannelThreadsConfigUpdateRequest, channelThreadsGuildActive, - channelThreadsGuildFieldsChanged, channelThreadsUserActive, channelThreadsUserExcluded, - channelThreadsUserFieldsChanged, compileChannelThreadsConfig, DEFAULT_CHANNEL_THREADS_CONFIG, - DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, + everyoneChannelThreadsConfig, resolveChannelThreadsAssignment, } from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; @@ -51,13 +47,8 @@ describe('channel threads configuration', () => { included_user_ids: [], excluded_user_ids: [], }); - expect(channelThreadsGuildActive(DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, GUILD_ID)).toBe(false); - expect(channelThreadsUserActive(DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, USER_ID)).toBe(false); - }); - - test('keeps partial updates free of defaults and server-maintained fields', () => { - expect(ChannelThreadsConfigUpdateRequest.parse({enabled: true})).toEqual({enabled: true}); - expect(ChannelThreadsConfigUpdateRequest.parse({config_version: 9, ever_enabled: false})).toEqual({}); + expect(channelThreadsGuildActive(compiled(), GUILD_ID)).toBe(false); + expect(channelThreadsUserActive(compiled(), USER_ID)).toBe(false); }); test.each([ @@ -69,9 +60,8 @@ describe('channel threads configuration', () => { {guild_salt: 'x'.repeat(65)}, {enabled_guild_ids: ['guild']}, {excluded_user_ids: ['123456789012345678901']}, - ])('rejects invalid values in stored config and updates: %j', (value) => { + ])('rejects invalid values: %j', (value) => { expect(ChannelThreadsConfigSchema.safeParse(value).success).toBe(false); - expect(ChannelThreadsConfigUpdateRequest.safeParse(value).success).toBe(false); }); test('caps every targeted id list at a thousand', () => { @@ -82,17 +72,23 @@ describe('channel threads configuration', () => { } }); - test('bumps the version on every update and keeps ever_enabled sticky', () => { - const first = applyChannelThreadsConfigUpdate(DEFAULT_CHANNEL_THREADS_CONFIG, {user_basis_points: 100}); - expect(first.config_version).toBe(1); - expect(first.ever_enabled).toBe(false); - const enabled = applyChannelThreadsConfigUpdate(first, {enabled: true}); - expect(enabled.config_version).toBe(2); - expect(enabled.ever_enabled).toBe(true); - const disabled = applyChannelThreadsConfigUpdate(enabled, {enabled: false}); - expect(disabled.config_version).toBe(3); - expect(disabled.enabled).toBe(false); - expect(disabled.ever_enabled).toBe(true); + test('the everyone config serves every guild and user and keeps the given version', () => { + const everyone = everyoneChannelThreadsConfig(42); + expect(everyone).toEqual({ + ...DEFAULT_CHANNEL_THREADS_CONFIG, + enabled: true, + config_version: 42, + ever_enabled: true, + guild_basis_points: 10000, + user_basis_points: 10000, + }); + expect(ChannelThreadsConfigSchema.parse(everyone)).toEqual(everyone); + const cfg = compileChannelThreadsConfig(everyone); + for (const id of syntheticIds(200)) { + expect(channelThreadsGuildActive(cfg, id)).toBe(true); + expect(channelThreadsUserActive(cfg, id)).toBe(true); + } + expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toEqual({active: true, config_version: 42}); }); }); @@ -144,24 +140,11 @@ describe('channel threads predicates', () => { } }); - test('the assignment is present only for active users and carries the version', () => { + test('the assignment is present only for active users and reports the version', () => { const cfg = compiled({enabled: true, config_version: 7, included_user_ids: [USER_ID]}); expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toEqual({active: true, config_version: 7}); expect(resolveChannelThreadsAssignment(cfg, OTHER_USER_ID)).toBeUndefined(); }); - - test('field change detection separates the guild and user dimensions', () => { - const base = config({enabled: true, enabled_guild_ids: [GUILD_ID], included_user_ids: [USER_ID]}); - expect(channelThreadsGuildFieldsChanged(base, {...base, config_version: 5})).toBe(false); - expect(channelThreadsUserFieldsChanged(base, {...base, config_version: 5})).toBe(false); - expect(channelThreadsGuildFieldsChanged(base, {...base, enabled_guild_ids: [OTHER_GUILD_ID]})).toBe(true); - expect(channelThreadsUserFieldsChanged(base, {...base, enabled_guild_ids: [OTHER_GUILD_ID]})).toBe(false); - expect(channelThreadsGuildFieldsChanged(base, {...base, excluded_user_ids: [USER_ID]})).toBe(false); - expect(channelThreadsUserFieldsChanged(base, {...base, excluded_user_ids: [USER_ID]})).toBe(true); - expect(channelThreadsGuildFieldsChanged(base, {...base, enabled_guild_ids: [GUILD_ID, GUILD_ID]})).toBe(false); - expect(channelThreadsGuildFieldsChanged(base, {...base, enabled: false})).toBe(true); - expect(channelThreadsUserFieldsChanged(base, {...base, enabled: false})).toBe(true); - }); }); describe('shared experiment bucket vectors', () => { diff --git a/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts b/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts index f3545de55..cea56ae5b 100644 --- a/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts +++ b/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts @@ -47,27 +47,23 @@ export type ChannelThreadsConfig = z.infer; export const DEFAULT_CHANNEL_THREADS_CONFIG: ChannelThreadsConfig = ChannelThreadsConfigSchema.parse({}); -export const ChannelThreadsConfigUpdateRequest = z - .object(channelThreadsConfigFields) - .omit({config_version: true, ever_enabled: true}) - .partial(); - -export type ChannelThreadsConfigUpdateRequest = z.infer; - export const ChannelThreadsConfigResponse = ChannelThreadsConfigSchema; export type ChannelThreadsConfigResponse = z.infer; -export function applyChannelThreadsConfigUpdate( - current: ChannelThreadsConfig, - update: ChannelThreadsConfigUpdateRequest, -): ChannelThreadsConfig { - const next = ChannelThreadsConfigSchema.parse({ - ...current, - ...update, - config_version: current.config_version + 1, - }); - return {...next, ever_enabled: current.ever_enabled || next.enabled}; +export function everyoneChannelThreadsConfig(configVersion: number): ChannelThreadsConfig { + return { + ...DEFAULT_CHANNEL_THREADS_CONFIG, + enabled: true, + config_version: configVersion, + ever_enabled: true, + guild_basis_points: CHANNEL_THREADS_BASIS_POINTS_MAX, + user_basis_points: CHANNEL_THREADS_BASIS_POINTS_MAX, + enabled_guild_ids: [], + disabled_guild_ids: [], + included_user_ids: [], + excluded_user_ids: [], + }; } export interface CompiledChannelThreadsConfig { @@ -88,9 +84,6 @@ export function compileChannelThreadsConfig(config: ChannelThreadsConfig): Compi }; } -export const DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG: CompiledChannelThreadsConfig = - compileChannelThreadsConfig(DEFAULT_CHANNEL_THREADS_CONFIG); - export function channelThreadsGuildActive(compiled: CompiledChannelThreadsConfig, guildId: string): boolean { const {config} = compiled; if (!config.enabled) return false; @@ -111,32 +104,6 @@ export function channelThreadsUserActive(compiled: CompiledChannelThreadsConfig, return config.user_basis_points > 0 && experimentBucket(userId, config.user_salt) < config.user_basis_points; } -function sameIds(a: ReadonlyArray, b: ReadonlyArray): boolean { - const left = new Set(a); - const right = new Set(b); - return left.size === right.size && [...right].every((id) => left.has(id)); -} - -export function channelThreadsGuildFieldsChanged(a: ChannelThreadsConfig, b: ChannelThreadsConfig): boolean { - return ( - a.enabled !== b.enabled || - a.guild_basis_points !== b.guild_basis_points || - a.guild_salt !== b.guild_salt || - !sameIds(a.enabled_guild_ids, b.enabled_guild_ids) || - !sameIds(a.disabled_guild_ids, b.disabled_guild_ids) - ); -} - -export function channelThreadsUserFieldsChanged(a: ChannelThreadsConfig, b: ChannelThreadsConfig): boolean { - return ( - a.enabled !== b.enabled || - a.user_basis_points !== b.user_basis_points || - a.user_salt !== b.user_salt || - !sameIds(a.included_user_ids, b.included_user_ids) || - !sameIds(a.excluded_user_ids, b.excluded_user_ids) - ); -} - export const ChannelThreadsAssignmentResponse = z.object({ active: z.literal(true), config_version: z.number().int().min(0), diff --git a/packages/schema/src/domains/admin/PlutoniumPageSchemas.test.ts b/packages/schema/src/domains/admin/PlutoniumPageSchemas.test.ts deleted file mode 100644 index 238e249ae..000000000 --- a/packages/schema/src/domains/admin/PlutoniumPageSchemas.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { - DEFAULT_PLUTONIUM_PAGE_CONFIG, - type PlutoniumPageConfig, - PlutoniumPageConfigSchema, - PlutoniumPageConfigUpdateRequest, - resolvePlutoniumPageAssignment, -} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas'; -import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; -import {describe, expect, test} from 'vitest'; - -const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false}; - -const TARGETED_USER_ID = '1000000000000000001'; - -function createConfig(overrides: Partial = {}): PlutoniumPageConfig { - return {...DEFAULT_PLUTONIUM_PAGE_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides}; -} - -function syntheticUserIds(count: number): Array { - return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString()); -} - -describe('plutonium page configuration', () => { - test('defaults to disabled', () => { - expect(PlutoniumPageConfigSchema.parse({})).toEqual({ - enabled: false, - config_version: 0, - rollout_basis_points: 0, - rollout_salt: 'plutonium-page-v1', - included_user_ids: [], - excluded_user_ids: [], - included_guild_ids: [], - include_premium_users: false, - }); - }); - - test('strips config_version from admin updates', () => { - expect(PlutoniumPageConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({}); - expect(PlutoniumPageConfigUpdateRequest.safeParse({rollout_basis_points: 10001}).success).toBe(false); - }); -}); - -describe('resolvePlutoniumPageAssignment', () => { - test('serves nobody while disabled, even included users', () => { - const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]}); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false}); - }); - - test('applies exclusions before inclusions', () => { - const config = createConfig({ - enabled: true, - included_user_ids: [TARGETED_USER_ID], - excluded_user_ids: [TARGETED_USER_ID], - }); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false}); - }); - - test('serves included users at zero rollout', () => { - const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]}); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true}); - }); - - test('buckets the rollout by salt and user id', () => { - const config = createConfig({enabled: true, rollout_basis_points: 2500}); - for (const userId of syntheticUserIds(200)) { - expect(resolvePlutoniumPageAssignment(config, userId, NO_TARGETING).enabled).toBe( - experimentBucket(userId, config.rollout_salt) < 2500, - ); - } - }); -}); - -describe('resolvePlutoniumPageAssignment guild targeting', () => { - const INCLUDED_GUILD_ID = '3000000000000000001'; - const MEMBER_GUILDS: ExperimentTargeting = { - memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]), - premium: false, - }; - - test('serves members of an included guild at zero rollout', () => { - const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]}); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true}); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false}); - }); - - test('keeps user exclusions ahead of guild membership', () => { - const config = createConfig({ - enabled: true, - included_guild_ids: [INCLUDED_GUILD_ID], - excluded_user_ids: [TARGETED_USER_ID], - }); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false}); - }); - - test('serves no guild members while disabled', () => { - const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]}); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false}); - }); -}); - -describe('resolvePlutoniumPageAssignment premium targeting', () => { - const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true}; - - test('serves premium users only when the switch is on', () => { - const on = createConfig({enabled: true, include_premium_users: true}); - expect(resolvePlutoniumPageAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true}); - expect(resolvePlutoniumPageAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false}); - expect(resolvePlutoniumPageAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({ - enabled: false, - }); - }); - - test('keeps user exclusions ahead of the premium switch', () => { - const config = createConfig({ - enabled: true, - include_premium_users: true, - excluded_user_ids: [TARGETED_USER_ID], - }); - expect(resolvePlutoniumPageAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false}); - }); -}); diff --git a/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts b/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts index 0d719a014..29d538e30 100644 --- a/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts +++ b/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts @@ -1,61 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { - EXPERIMENT_BUCKET_RESOLUTION, - type ExperimentTargeting, - experimentAudienceIncludes, - experimentBucket, -} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; import {z} from 'zod'; -const PLUTONIUM_PAGE_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION; -const PLUTONIUM_PAGE_MAX_TARGETED_USERS = 1000; -const DEFAULT_PLUTONIUM_PAGE_SALT = 'plutonium-page-v1'; - -const PLUTONIUM_PAGE_SALT_PATTERN = /^[\x20-\x7e]+$/u; - -const PlutoniumPageTargetIdSchema = z.string().regex(/^\d{1,20}$/u); -const PlutoniumPageTargetedUserIdsSchema = z.array(PlutoniumPageTargetIdSchema).max(PLUTONIUM_PAGE_MAX_TARGETED_USERS); - -const plutoniumPageConfigFields = { - enabled: z.boolean(), - config_version: z.number().int().min(0), - rollout_basis_points: z.number().int().min(0).max(PLUTONIUM_PAGE_ROLLOUT_BASIS_POINTS_MAX), - rollout_salt: z.string().trim().min(1).max(64).regex(PLUTONIUM_PAGE_SALT_PATTERN), - included_user_ids: PlutoniumPageTargetedUserIdsSchema, - included_guild_ids: PlutoniumPageTargetedUserIdsSchema, - include_premium_users: z.boolean(), - excluded_user_ids: PlutoniumPageTargetedUserIdsSchema, -}; - -export const PlutoniumPageConfigSchema = z.object({ - enabled: plutoniumPageConfigFields.enabled.default(false), - config_version: plutoniumPageConfigFields.config_version.default(0), - rollout_basis_points: plutoniumPageConfigFields.rollout_basis_points.default(0), - rollout_salt: plutoniumPageConfigFields.rollout_salt.default(DEFAULT_PLUTONIUM_PAGE_SALT), - included_user_ids: plutoniumPageConfigFields.included_user_ids.default([]), - included_guild_ids: plutoniumPageConfigFields.included_guild_ids.default([]), - include_premium_users: plutoniumPageConfigFields.include_premium_users.default(false), - excluded_user_ids: plutoniumPageConfigFields.excluded_user_ids.default([]), -}); - -export type PlutoniumPageConfig = z.infer; - -export const DEFAULT_PLUTONIUM_PAGE_CONFIG: PlutoniumPageConfig = PlutoniumPageConfigSchema.parse({}); - -export const PlutoniumPageConfigUpdateRequest = z - .object(plutoniumPageConfigFields) - .omit({config_version: true}) - .partial(); - -export type PlutoniumPageConfigUpdateRequest = z.infer; - -export const PlutoniumPageConfigResponse = PlutoniumPageConfigSchema; - -export type PlutoniumPageConfigResponse = z.infer; - export const PlutoniumPageAssignmentResponse = z.object({ - enabled: plutoniumPageConfigFields.enabled, + enabled: z.boolean(), }); export type PlutoniumPageAssignmentResponse = z.infer; @@ -63,15 +11,3 @@ export type PlutoniumPageAssignmentResponse = z.infer