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}, + )