Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/19853.feature
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,9 @@ def read_config(
) -> None:
experimental = config.get("experimental_features") or {}

# MSC1763 (retention policy configuration endpoint)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A future PR could fold the per-room state changes into this experimental feature, but for now it's just the config.

self.msc1763_enabled: bool = experimental.get("msc1763_enabled", False)

# MSC3026 (busy presence state)
self.msc3026_enabled: bool = experimental.get("msc3026_enabled", False)

Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
relations,
rendezvous,
reporting,
retention,
room,
room_keys,
room_upgrade_rest_servlet,
Expand Down Expand Up @@ -107,6 +108,7 @@
tags.register_servlets,
account_data.register_servlets,
reporting.register_servlets,
retention.register_servlets,
openid.register_servlets,
notifications.register_servlets,
devices.register_servlets,
Expand Down
102 changes: 102 additions & 0 deletions synapse/rest/client/retention.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +100 to +102

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server-wide retention is kinda considered stable in Synapse (it's at least in the stable config) but this is introducing an unstable endpoint.

Could you gate this behind an experimental feature?

It's not great that Synapse is considering unstable room-level retention state events if retention.enabled is set, ideally we would introduce an experimental feature for this as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair cop. I'll do the do.

122 changes: 121 additions & 1 deletion tests/rest/client/test_retention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +26,7 @@
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
Expand Down Expand Up @@ -394,3 +395,122 @@ def get_event(
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},
)
Loading