From d3fc81974a5d5722fd5ba5205945affd30bc0564 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:37:02 +0100 Subject: [PATCH] MSC1763: Implement /_matrix/client/unstable/org.matrix.msc1763/retention/configuration (#19853) Implements https://github.com/matrix-org/matrix-spec-proposals/pull/1763 Tracking issue https://github.com/element-hq/synapse/issues/19852 This only implements the MSC's configuration endpoint and does not add new configuration fields or options. The reason for including this is to allow clients to run global retention against locally stored events, and to be able to offer configuration UI. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- changelog.d/19853.feature | 3 + synapse/config/experimental.py | 3 + synapse/rest/__init__.py | 2 + synapse/rest/client/retention.py | 102 +++++++++++++++++++++++ tests/rest/client/test_retention.py | 122 +++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 changelog.d/19853.feature create mode 100644 synapse/rest/client/retention.py diff --git a/changelog.d/19853.feature b/changelog.d/19853.feature new file mode 100644 index 0000000000..b11eaa16d7 --- /dev/null +++ b/changelog.d/19853.feature @@ -0,0 +1,3 @@ +The `GET /_matrix/client/unstable/org.matrix.msc1763/retention/configuration` endpoint is now provided when retention +is enabled and `experimental_features.msc1763_enabled` is enabled, based on +[MSC1763](https://github.com/matrix-org/matrix-spec-proposals/pull/1763). diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index 96f7169b32..120b31cee5 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -380,6 +380,9 @@ class ExperimentalConfig(Config): ) -> None: experimental = config.get("experimental_features") or {} + # MSC1763 (retention policy configuration endpoint) + self.msc1763_enabled: bool = experimental.get("msc1763_enabled", False) + # MSC3026 (busy presence state) self.msc3026_enabled: bool = experimental.get("msc3026_enabled", False) diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py index fe66494d82..a56a81a8e9 100644 --- a/synapse/rest/__init__.py +++ b/synapse/rest/__init__.py @@ -57,6 +57,7 @@ from synapse.rest.client import ( relations, rendezvous, reporting, + retention, room, room_keys, room_upgrade_rest_servlet, @@ -107,6 +108,7 @@ CLIENT_SERVLET_FUNCTIONS: tuple[RegisterServletsFunc, ...] = ( tags.register_servlets, account_data.register_servlets, reporting.register_servlets, + retention.register_servlets, openid.register_servlets, notifications.register_servlets, devices.register_servlets, diff --git a/synapse/rest/client/retention.py b/synapse/rest/client/retention.py new file mode 100644 index 0000000000..922b6cfb69 --- /dev/null +++ b/synapse/rest/client/retention.py @@ -0,0 +1,102 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . + +from http import HTTPStatus +from typing import TYPE_CHECKING + +from typing_extensions import TypedDict + +from synapse.http.server import HttpServer +from synapse.http.servlet import RestServlet +from synapse.http.site import SynapseRequest + +from ._base import client_patterns + +if TYPE_CHECKING: + from synapse.server import HomeServer + + +class RetentionPolicyDict(TypedDict, total=False): + min_lifetime: int + max_lifetime: int + + +class LifetimeBoundsDict(TypedDict, total=False): + min: int + max: int + + +class RetentionLimitsDict(TypedDict, total=False): + max_lifetime: LifetimeBoundsDict + + +class RetentionConfigurationResponse(TypedDict): + policies: dict[str, RetentionPolicyDict] + limits: RetentionLimitsDict + + +class RetentionConfigurationServlet(RestServlet): + """Implements MSC1763: /_matrix/client/unstable/org.matrix.msc1763/retention/configuration""" + + PATTERNS = client_patterns( + "/org.matrix.msc1763/retention/configuration$", + releases=[], + v1=False, + unstable=True, + ) + CATEGORY = "Client API requests" + + def __init__(self, hs: "HomeServer"): + super().__init__() + self.auth = hs.get_auth() + self._retention_config = hs.config.retention + + async def on_GET( + self, request: SynapseRequest + ) -> tuple[int, RetentionConfigurationResponse]: + await self.auth.get_user_by_req(request) + + default_policy: RetentionPolicyDict = {} + if self._retention_config.retention_default_min_lifetime is not None: + default_policy["min_lifetime"] = ( + self._retention_config.retention_default_min_lifetime + ) + if self._retention_config.retention_default_max_lifetime is not None: + default_policy["max_lifetime"] = ( + self._retention_config.retention_default_max_lifetime + ) + + max_lifetime_limits: LifetimeBoundsDict = {} + if self._retention_config.retention_allowed_lifetime_min is not None: + max_lifetime_limits["min"] = ( + self._retention_config.retention_allowed_lifetime_min + ) + if self._retention_config.retention_allowed_lifetime_max is not None: + max_lifetime_limits["max"] = ( + self._retention_config.retention_allowed_lifetime_max + ) + + limits: RetentionLimitsDict = {} + if max_lifetime_limits: + limits["max_lifetime"] = max_lifetime_limits + + policies: dict[str, RetentionPolicyDict] = {} + if default_policy: + policies["*"] = default_policy + + return HTTPStatus.OK, {"policies": policies, "limits": limits} + + +def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: + if hs.config.retention.retention_enabled and hs.config.experimental.msc1763_enabled: + RetentionConfigurationServlet(hs).register(http_server) diff --git a/tests/rest/client/test_retention.py b/tests/rest/client/test_retention.py index 1f8d9154ca..3fe1c1bc89 100644 --- a/tests/rest/client/test_retention.py +++ b/tests/rest/client/test_retention.py @@ -2,6 +2,7 @@ # This file is licensed under the Affero General Public License (AGPL) version 3. # # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2026 Element Creations Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -25,7 +26,7 @@ from twisted.internet.testing import MemoryReactor from synapse.api.constants import EventTypes from synapse.events.utils import FilteredEvent from synapse.rest import admin -from synapse.rest.client import login, room +from synapse.rest.client import login, retention, room from synapse.server import HomeServer from synapse.types import JsonDict, create_requester from synapse.util.clock import Clock @@ -394,3 +395,122 @@ class RetentionNoDefaultPolicyTestCase(unittest.HomeserverTestCase): self.assertEqual(channel.code, expected_code, channel.result) return channel.json_body + + +RETENTION_CONFIGURATION_URL = ( + "/_matrix/client/unstable/org.matrix.msc1763/retention/configuration" +) + + +class RetentionConfigurationEndpointTestCase(unittest.HomeserverTestCase): + servlets = [ + admin.register_servlets, + login.register_servlets, + retention.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.register_user("user", "password") + self.token = self.login("user", "password") + + @override_config( + { + "experimental_features": {"msc1763_enabled": True}, + } + ) + def test_disabled_returns_404_no_retention(self) -> None: + """The endpoint must 404 when retention is not enabled.""" + channel = self.make_request( + "GET", RETENTION_CONFIGURATION_URL, access_token=self.token + ) + self.assertEqual(channel.code, 404, channel.result) + + @override_config( + { + "retention": { + "enabled": True, + "default_policy": { + "min_lifetime": one_day_ms, + "max_lifetime": one_day_ms * 3, + }, + "allowed_lifetime_min": one_day_ms, + "allowed_lifetime_max": one_day_ms * 3, + }, + "experimental_features": {"msc1763_enabled": False}, + } + ) + def test_disabled_returns_404_no_msc1763_enabled(self) -> None: + """The endpoint must 404 when retention is not enabled.""" + channel = self.make_request( + "GET", RETENTION_CONFIGURATION_URL, access_token=self.token + ) + self.assertEqual(channel.code, 404, channel.result) + + @override_config( + { + "retention": { + "enabled": True, + "default_policy": { + "min_lifetime": one_day_ms, + "max_lifetime": one_day_ms * 3, + }, + "allowed_lifetime_min": one_day_ms, + "allowed_lifetime_max": one_day_ms * 3, + }, + "experimental_features": {"msc1763_enabled": True}, + } + ) + def test_full_config(self) -> None: + """Returns default policy and max_lifetime limits when fully configured.""" + channel = self.make_request( + "GET", RETENTION_CONFIGURATION_URL, access_token=self.token + ) + self.assertEqual(channel.code, 200, channel.result) + body = channel.json_body + self.assertEqual( + body["policies"]["*"], + {"min_lifetime": one_day_ms, "max_lifetime": one_day_ms * 3}, + ) + self.assertEqual( + body["limits"]["max_lifetime"], + {"min": one_day_ms, "max": one_day_ms * 3}, + ) + + @override_config( + { + "retention": {"enabled": True}, + "experimental_features": {"msc1763_enabled": True}, + } + ) + def test_no_default_policy_no_limits(self) -> None: + """Returns empty policies and limits when nothing is configured.""" + channel = self.make_request( + "GET", RETENTION_CONFIGURATION_URL, access_token=self.token + ) + self.assertEqual(channel.code, 200, channel.result) + body = channel.json_body + self.assertEqual(body["policies"], {}) + self.assertEqual(body["limits"], {}) + + @override_config( + { + "retention": { + "enabled": True, + "allowed_lifetime_min": one_day_ms, + "allowed_lifetime_max": one_day_ms * 7, + }, + "experimental_features": {"msc1763_enabled": True}, + } + ) + def test_limits_only(self) -> None: + """Returns limits but no default policy entry when only limits are set.""" + channel = self.make_request( + "GET", RETENTION_CONFIGURATION_URL, access_token=self.token + ) + self.assertEqual(channel.code, 200, channel.result) + body = channel.json_body + self.assertNotIn("*", body["policies"]) + self.assertEqual( + body["limits"]["max_lifetime"], + {"min": one_day_ms, "max": one_day_ms * 7}, + )