diff --git a/docs/development/unstable_features.mdx b/docs/development/unstable_features.mdx new file mode 100644 index 000000000..eb1daf7fd --- /dev/null +++ b/docs/development/unstable_features.mdx @@ -0,0 +1,75 @@ +# Unstable features policy + +Like several other homeservers in the Matrix ecosystem, Continuwuity adopts a "disable by default" +approach to experimental features. This ensures every deployment gets the same baseline stability, +allowing users to opt in to potentially unstable features. + +Historically, Continuwuity used maintainer discretion to decide whether an unstable feature should +be enabled by default. However, as the project has grown, and the pace of spec development has +increased, keeping up with unstable proposals becomes less and less feasible by the day, and this +causes the implementation of unstable proposals to gradually drift. This can lead to implementations +becoming so stale they're incompatible, with no bandwidth to get them fixed up. + +Continuwuity is already selective about which unstable features it implements: the maintainer team +typically only accept implementations of proposals that seem like they are a net positive for a +large portion of the Matrix ecosystem, rather than aiming to "implement everything". As such, the +unstable feature list should typically be small and manageable. + +## Adding an unstable feature + +First, navigate to `src/core/config/experiments.rs`. There, you have two options for adding an +experiment to the `Experiments` struct: + +1. Use `SimpleExperiment` if the experiment only requires enabling with no further configuration. +2. Define a new struct that implements the `ToggledExperiment` trait. + +All experiments must be optional and default to off (i.e. `is_enabled()` returns `false`). +`SimpleExperiment` is actually a wrapper around `Option`, and the omission of the +configuration OR setting it to `false` disables the experiment. `ToggledExperiment` impls should +copy similar behaviour. + +Then, add a public field to the `Experiments` struct that is named the MSC's ID in lowercase. +Add a docstring to it, starting with the proposal title (including ID), then a brief paragraph or +so explaining what the proposal does in simple terms, and then finally end the docstring with a link +to the proposal. + +### Advertising support for unstable features + +If the proposal requires advertising an unstable feature flag, that flag must be added to +`unstable_features` in `src/core/matrix/versions.rs`. For example: + +```diff +pub fn unstable_features(experiments: &Experiments) -> BTreeMap { + BTreeMap::from_iter([ + // Always enabled feature (https://github.com/matrix-org/matrix-spec-proposals/pull/0000) + // Expected for spec v1.20 + ("org.matrix.msc0000".to_owned(), true), ++ // MSC9999: My Cool Feature (https://github.com/matrix-org/matrix-spec-proposals/pull/9999) ++ ("example.matrix.msc9999".to_owned(), experiments.msc9999.is_enabled()) + ]) +} +``` + +This will allow the `/_matrix/client/versions` endpoint to dynamically advertise whether the unstable +feature is enabled on the current server. + +## Removing unstable features + +If a proposal is abandoned, becomes obsolete, or is accepted into the specification, the unstable +configuration should be removed (and possibly migrated) as soon as possible. + +After removing a field from `experiments`, the key should be added to the `DEPRECATED_KEYS` slice in +`src/core/config/mod.rs` (really far down). This will ensure that a warning is printed on startup +when a configuration is using a now-removed config key. For example: + +```diff +pub struct Experiments { +- pub msc9999: SimpleExperiment, +} +``` +```diff +const DEPRECATED_KEYS: &[&str] = [ + "foo", ++ "experiments.msc9999", +]; +``` diff --git a/src/api/client/room/event.rs b/src/api/client/room/event.rs index 379cc2dd6..d4917f73a 100644 --- a/src/api/client/room/event.rs +++ b/src/api/client/room/event.rs @@ -1,5 +1,7 @@ use axum::extract::{Query, State}; -use conduwuit::{Err, Error, Event, PduEvent, Result, debug_warn, err}; +use conduwuit::{ + Err, Error, Event, PduEvent, Result, config::experiments::ToggledExperiment, debug_warn, err, +}; use futures::TryFutureExt; use http::StatusCode; use ruma::{ @@ -68,7 +70,7 @@ pub(crate) async fn get_room_event_route( return Err!(Request(Forbidden("You don't have permission to view this event."))); } - if services.config.allow_moderators_to_view_redacted_content + if services.config.experiments.msc2815.is_enabled() && params.include_unredacted_content.unwrap_or(false) && event.is_redacted() { @@ -126,6 +128,9 @@ async fn unredact(services: &crate::State, sender_user: &UserId, event: &mut Pdu // CustomErrorKind has private fields; only serde can build one. let keep_ms = services .config + .experiments + .msc2815 + .expect("msc2815 config is disabled but unredact() attempted to access it anyway") .unredacted_content_max_age_secs .saturating_mul(1000); diff --git a/src/api/client/send.rs b/src/api/client/send.rs index b87f7eb2e..41e3804b0 100644 --- a/src/api/client/send.rs +++ b/src/api/client/send.rs @@ -95,12 +95,7 @@ pub(crate) async fn send_message_event_route( PartialPdu { event_type: body.event_type.clone().into(), content, - sticky: services - .config - .allow_sticky_events - .then_some(body.sticky_duration_ms) - .flatten() - .map(sticky::object), + sticky: body.sticky_duration_ms.map(sticky::object), unsigned: Some(unsigned), timestamp: if body.identity.is_appservice() || services.admin.user_is_admin(sender_user).await diff --git a/src/api/client/sync/v3/joined.rs b/src/api/client/sync/v3/joined.rs index b47753518..7eee0d1d5 100644 --- a/src/api/client/sync/v3/joined.rs +++ b/src/api/client/sync/v3/joined.rs @@ -273,10 +273,6 @@ async fn build_sticky_events( timeline: &TimelinePdus, joined_since_last_sync: bool, ) -> Sticky { - if !services.config.allow_sticky_events { - return Sticky::new(); - } - let SyncContext { syncing_user, last_sync_end_count, diff --git a/src/api/client/sync/v5.rs b/src/api/client/sync/v5.rs index e97319e46..0285c8359 100644 --- a/src/api/client/sync/v5.rs +++ b/src/api/client/sync/v5.rs @@ -1123,7 +1123,7 @@ async fn collect_sticky_events( let mut delivered = StickyDelivered::new(); let sticky_events = &body.extensions.sticky_events; - if !services.config.allow_sticky_events || !sticky_events.enabled.unwrap_or(false) { + if !sticky_events.enabled.unwrap_or(false) { return (response, delivered); } diff --git a/src/api/client/unversioned.rs b/src/api/client/unversioned.rs index f35a074ac..39862673b 100644 --- a/src/api/client/unversioned.rs +++ b/src/api/client/unversioned.rs @@ -27,17 +27,7 @@ pub(crate) async fn get_supported_versions_route( State(services): State, _body: Ruma, ) -> Result { - let mut unstable_features = unstable_features(); - - if services.config.allow_sticky_events { - // sticky events (https://github.com/matrix-org/matrix-spec-proposals/pull/4354) - unstable_features.insert("org.matrix.msc4354".to_owned(), true); - } - - if services.config.allow_moderators_to_view_redacted_content { - // moderators can view redacted content (https://github.com/matrix-org/matrix-spec-proposals/pull/2815) - unstable_features.insert("fi.mau.msc2815".to_owned(), true); - } + let unstable_features = unstable_features(&services.config.experiments); Ok(assign!(get_supported_versions::Response::new(versions()), { unstable_features diff --git a/src/core/config/experiments.rs b/src/core/config/experiments.rs new file mode 100644 index 000000000..82a49e0c5 --- /dev/null +++ b/src/core/config/experiments.rs @@ -0,0 +1,115 @@ +use serde::Deserialize; + +/// Configuration for experimental features. +#[derive(Debug, Deserialize, Default, Clone)] +pub struct Experiments { + /// MSC2815: Proposal to allow room moderators to view redacted event + /// content + /// + /// Allows the server to retain unredacted copies of events that have been + /// redacted for a limited period of time, which allows room moderators and + /// server administrators to reveal redacted contents. Useful for moderation + /// and investigating reports. + /// + /// Requires a supporting client to use. + /// + /// https://github.com/matrix-org/matrix-spec-proposals/pull/2815 + #[serde(default)] + pub msc2815: Option, + /// MSC4155: Invite filtering + /// + /// Provides advanced invite filtering rules for users, allowing complex + /// configurations that can allow, ignore, and reject invites from users and + /// servers broadly. + /// + /// Requires a supporting client to set up on an account. + /// + /// https://github.com/matrix-org/matrix-spec-proposals/pull/4155 + #[serde(default)] + pub msc4155: SimpleExperiment, + /// MSC4174: Web push + /// + /// Adds server-side support for web push, allowing web clients to receive + /// push notifications directly without the need for an intermediary push + /// gateway like Sygnal. + /// + /// https://github.com/matrix-org/matrix-spec-proposals/pull/4174 + #[serde(default)] + pub msc4174: SimpleExperiment, + /// MSC4484: Server Administration OAuth Scope + /// + /// Adds a new OAuth2 scope that explicitly grants a token temporary + /// homeserver administrator privileges, granting temporary token access + /// to administrator endpoints like locking & suspension. + /// + /// https://github.com/matrix-org/matrix-spec-proposals/pull/4484 + #[serde(default)] + pub msc4484: SimpleExperiment, + /// MSC4494: Membership-based invite blocking + /// + /// Adds a new invite blocking action that will reject all invites coming + /// from users who do not share a room with the invitee that is not `public` + /// (the join rule). + /// + /// This is not related to MSC4155, this proposal instead builds on the + /// "invite blocking" proposal that landed in the specification in v1.17, + /// which simply allows enabling or disabling invites altogether. + /// + /// MSC4494 is a good middle-ground between the allowlisting possible with + /// MSC4155, and the general desire to block invites from users you may not + /// know. + /// + /// Requires a supporting client to enable on an account. + /// + /// https://github.com/matrix-org/matrix-spec-proposals/pull/4494 + #[serde(default)] + pub msc4494: SimpleExperiment, + /// MSC4560: Stripped state requests over federation + /// + /// Implements a more efficient way for servers to acquire and serve + /// "stripped state" to clients, which is typically only used for displaying + /// rooms. The display of space hierarchies and room summaries is + /// significantly faster when this proposal is enabled and used. + /// + /// https://github.com/matrix-org/matrix-spec-proposals/pull/4560 + #[serde(default)] + pub msc4560: SimpleExperiment, +} + +/// Represents a "simple" experiment, which is either disabled or enabled. +/// +/// More complex experiments that require configuration should define their own +/// type and implement `ToggledExperiment` instead. +#[derive(Debug, Deserialize, Default, Clone, Copy)] +pub struct SimpleExperiment(Option); + +pub trait ToggledExperiment { + /// Returns true if this experiment is explicitly enabled. + #[must_use] + fn is_enabled(&self) -> bool; +} + +impl ToggledExperiment for SimpleExperiment { + #[inline] + fn is_enabled(&self) -> bool { self.0.is_some_and(crate::is_true!()) } +} + +/// Configuration for [MSC2815]: Proposal to allow room moderators to view +/// redacted event content +/// +/// MSC2815: https://github.com/matrix-org/matrix-spec-proposals/pull/2815 +#[derive(Debug, Deserialize, Default, Clone, Copy)] +pub struct MSC2815 { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_unredacted_content_max_age_secs")] + pub unredacted_content_max_age_secs: u64, +} + +#[inline] +fn default_unredacted_content_max_age_secs() -> u64 { 7_776_000 } + +impl ToggledExperiment for Option { + #[inline] + fn is_enabled(&self) -> bool { self.is_some_and(|config| config.enabled) } +} diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 831a052a6..be2461f50 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -1,5 +1,6 @@ #![allow(clippy::doc_link_with_quotes)] pub mod check; +pub mod experiments; pub mod manager; pub mod proxy; @@ -18,6 +19,7 @@ use either::{ Either, Either::{Left, Right}, }; +pub use experiments::Experiments; use figment::providers::{Env, Format, Toml}; pub use figment::{Figment, value::Value as FigmentValue}; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; @@ -1768,35 +1770,6 @@ pub struct Config { #[serde(default = "true_fn")] pub allow_incoming_typing: bool, - /// Allow the use of unstable persistent "sticky events" ([MSC4354]) - /// - /// MSC4354: https://c10y.cc/MSC4354 - /// - /// default: false - #[serde(default)] - pub allow_sticky_events: bool, - - /// Allow room moderators to view redacted event content ([MSC2815]). - /// - /// Redacted content is readable by users at or above the room's `redact` - /// power level, and by server admins, until it expires. - /// - /// MSC2815: https://github.com/matrix-org/matrix-spec-proposals/pull/2815 - /// - /// default: false - #[serde(default)] - pub allow_moderators_to_view_redacted_content: bool, - - /// How long to retain the content of redacted events, in seconds. Expired - /// content is deleted by a periodic purge and is no longer served. - /// - /// Only has an effect when allow_moderators_to_view_redacted_content is - /// enabled. - /// - /// default: 7776000 (90 days) - #[serde(default = "default_unredacted_content_max_age_secs")] - pub unredacted_content_max_age_secs: u64, - /// Maximum time federation user can indicate typing. /// /// default: 30 @@ -2474,6 +2447,12 @@ pub struct Config { #[serde(default)] pub matrix_rtc: MatrixRtcConfig, + /// Configuration for unstable features. + /// + /// display: none + #[serde(default)] + pub experiments: Experiments, + #[serde(flatten)] #[allow(clippy::zero_sized_map_values)] // this is a catchall, the map shouldn't be zero at runtime @@ -3007,6 +2986,9 @@ const DEPRECATED_KEYS: &[&str] = &[ "well_known_support_email", "well_known_support_mxid", "well_known.rtc_focus_server_urls", + "allow_sticky_events", + "allow_moderators_to_view_redacted_content", + "unredacted_content_max_age_secs", ]; impl Config { @@ -3239,8 +3221,6 @@ fn default_remote_join_pdu_queue_capacity() -> usize { 50 } fn default_transaction_id_cache_max_age_secs() -> u64 { 60 * 60 * 2 } -fn default_unredacted_content_max_age_secs() -> u64 { 60 * 60 * 24 * 90 } - fn default_transaction_id_cache_max_entries() -> usize { 8192 } fn default_tracing_flame_filter() -> String { diff --git a/src/core/matrix/versions.rs b/src/core/matrix/versions.rs index 3ea538b62..160efe085 100644 --- a/src/core/matrix/versions.rs +++ b/src/core/matrix/versions.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; +use crate::config::{Experiments, experiments::ToggledExperiment}; + #[must_use] pub fn versions() -> Vec { vec![ @@ -25,32 +27,26 @@ pub fn versions() -> Vec { "v1.16".to_owned(), "v1.17".to_owned(), "v1.18".to_owned(), + "v1.19".to_owned(), ] } #[must_use] -pub fn unstable_features() -> BTreeMap { +pub fn unstable_features(experiments: &Experiments) -> BTreeMap { BTreeMap::from_iter([ // MatrixRTC (https://github.com/matrix-org/matrix-spec-proposals/pull/4143) + // Expected for spec v1.20 ("org.matrix.msc4143".to_owned(), true), - // query mutual rooms (https://github.com/matrix-org/matrix-spec-proposals/pull/2666) - // Expected for spec v1.19 - ("uk.half-shot.msc2666.query_mutual_rooms".to_owned(), true), - ("uk.half-shot.msc2666.query_mutual_rooms.stable".to_owned(), true), // Simplified Sliding sync (https://github.com/matrix-org/matrix-spec-proposals/pull/4186) - // Expected for spec v1.19 + // Expected for spec v1.20 ("org.matrix.simplified_msc3575".to_owned(), true), - // invite filtering (https://github.com/matrix-org/matrix-spec-proposals/pull/4155) - ("org.matrix.msc4155".to_owned(), true), + ("org.matrix.msc4155".to_owned(), experiments.msc4155.is_enabled()), // profile change propagation (https://github.com/matrix-org/matrix-spec-proposals/pull/4466) ("computer.gingershaped.msc4466".to_owned(), true), - // web push (https://github.com/matrix-org/matrix-spec-proposals/pull/4174) - ("org.matrix.msc4174".to_owned(), true), - // Membership-based invite blocking (https://github.com/matrix-org/matrix-spec-proposals/pull/4494) - ("uk.timedout.msc4494".to_owned(), true), - // server administration OAuth scope (https://github.com/matrix-org/matrix-spec-proposals/pull/4484) - ("org.continuwuity.msc4484.unstable".to_owned(), true), - // stripped state over federation (https://github.com/matrix-org/matrix-spec-proposals/pull/4560) - ("org.continuwuity.msc4560".to_owned(), true), + ("org.matrix.msc4174".to_owned(), experiments.msc4174.is_enabled()), + ("uk.timedout.msc4494".to_owned(), experiments.msc4494.is_enabled()), + ("org.continuwuity.msc4484.unstable".to_owned(), experiments.msc4484.is_enabled()), + ("org.continuwuity.msc4560".to_owned(), experiments.msc4560.is_enabled()), + ("fi.mau.msc2815".to_owned(), experiments.msc2815.is_enabled()), ]) } diff --git a/src/service/mod.rs b/src/service/mod.rs index 1a5505583..9a8722cff 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -42,6 +42,8 @@ pub mod transactions; pub mod uiaa; pub mod users; +use std::collections::BTreeMap; + pub(crate) use service::{Args, Dep, Service}; pub use crate::services::Services; @@ -51,8 +53,8 @@ conduwuit::mod_dtor! {} use std::sync::LazyLock; -use conduwuit::matrix::versions::{unstable_features, versions}; +use conduwuit::matrix::versions::versions; use ruma::api::SupportedVersions; pub static SUPPORTED_VERSIONS: LazyLock = - LazyLock::new(|| SupportedVersions::from_parts(&versions(), &unstable_features())); + LazyLock::new(|| SupportedVersions::from_parts(&versions(), &BTreeMap::new())); diff --git a/src/service/rooms/timeline/append.rs b/src/service/rooms/timeline/append.rs index d0cfd058e..c4b66e866 100644 --- a/src/service/rooms/timeline/append.rs +++ b/src/service/rooms/timeline/append.rs @@ -591,9 +591,7 @@ impl super::Service { room_id: &RoomId, ) -> Option { let server = target_user_id.server_name(); - if !self.services.config.allow_sticky_events - || self.services.globals.server_is_ours(server) - { + if self.services.globals.server_is_ours(server) { return None; } diff --git a/src/service/rooms/timeline/helpers.rs b/src/service/rooms/timeline/helpers.rs index 170b35889..dc5db990e 100644 --- a/src/service/rooms/timeline/helpers.rs +++ b/src/service/rooms/timeline/helpers.rs @@ -48,13 +48,7 @@ impl super::Service { PartialPdu { event_type: event_type.to_string().into(), content, - sticky: self - .services - .config - .allow_sticky_events - .then_some(sticky_duration_ms) - .flatten() - .map(sticky::object), + sticky: sticky_duration_ms.map(sticky::object), state_key: Some(state_key.into()), timestamp, ..Default::default() diff --git a/src/service/rooms/timeline/mod.rs b/src/service/rooms/timeline/mod.rs index 69c7faa70..6c206d9bf 100644 --- a/src/service/rooms/timeline/mod.rs +++ b/src/service/rooms/timeline/mod.rs @@ -287,7 +287,12 @@ impl Service { } fn unredacted_content_cutoff(&self) -> u64 { - let max_age = self.services.config.unredacted_content_max_age_secs; + let max_age = self + .services + .config + .experiments + .msc2815 + .map_or_default(|c| c.unredacted_content_max_age_secs); utils::millis_since_unix_epoch().saturating_sub(max_age.saturating_mul(1000)) } diff --git a/src/service/rooms/timeline/redact.rs b/src/service/rooms/timeline/redact.rs index d6a67be63..af69fcb36 100644 --- a/src/service/rooms/timeline/redact.rs +++ b/src/service/rooms/timeline/redact.rs @@ -1,3 +1,4 @@ +use conduwuit::config::experiments::ToggledExperiment; use conduwuit_core::{ Result, err, matrix::event::Event, @@ -52,11 +53,7 @@ impl super::Service { .get_room_version(&pdu.room_id_or_hash()) .await?; - if self - .services - .config - .allow_moderators_to_view_redacted_content - { + if self.services.config.experiments.msc2815.is_enabled() { let mut pdu_json = self.get_pdu_json_from_id(&pdu_id).await?; pdu_json.remove("event_id"); if content_hash(&pdu_json) diff --git a/src/service/sending/sender.rs b/src/service/sending/sender.rs index 03be9e2ce..26cb371b1 100644 --- a/src/service/sending/sender.rs +++ b/src/service/sending/sender.rs @@ -327,7 +327,7 @@ impl Service { /// still-sticky events. Only netburst delivers this queue, so with it /// disabled there is nothing to keep the event for. async fn survives_netburst_trim(&self, event: &SendingEvent) -> bool { - if !self.server.config.allow_sticky_events || !self.server.config.startup_netburst { + if !self.server.config.startup_netburst { return false; }