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>
This commit is contained in:
Will Hunt
2026-06-18 17:37:02 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent b38106d58b
commit d3fc81974a
5 changed files with 231 additions and 1 deletions
+3
View File
@@ -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).
+3
View File
@@ -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)
+2
View File
@@ -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,
+102
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
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)
+121 -1
View File
@@ -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},
)