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
78 changes: 58 additions & 20 deletions modules/room_service/README
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
This module implements a custom room management service for Matrix Synapse.

It exposes a set of **authenticated HTTP endpoints** that allow external systems
(frontend, admin panels or backend services) to **discover, create and manage rooms**
with additional **business rules** such as visibility, access type, pricing and keywords.
(frontend, admin panels or backend services) to **discover, create and manage rooms
and spaces** with additional **business rules** such as visibility, access type,
pricing and keywords.

The module integrates directly with Synapse internals and uses the **admin API**
to perform privileged operations.
Expand All @@ -16,17 +17,32 @@ to perform privileged operations.
This module allows Synapse to:

- expose custom REST endpoints for room management
- discover visible rooms with metadata
- create rooms with business configuration
- discover visible rooms and spaces with metadata
- create rooms and spaces with business configuration
- join users to rooms using keywords
- control room visibility (public / private)
- manage paid and free rooms
- change access type dynamically
- safely delete rooms
- safely delete rooms and spaces
- enforce admin-only operations

---

## Rooms vs Spaces

A **space** is a Matrix room of type `m.space` — a container that organises
other rooms (like a folder). This module treats spaces as first-class rooms:
they live in the same `room_business` table, differ only by `room_kind`, and go
through the same endpoints.

Spaces here are **organisational only**: they are always free (`price = 0`) and
do not gate access to the rooms inside them (Matrix keeps space membership and
child-room access independent). A public space appears in discover alongside
public rooms; a private space is not listed. Selling/bundling rooms is handled
separately (bundles), not by spaces.

---

## How it works

1. Synapse loads the module on startup
Expand All @@ -52,28 +68,31 @@ All endpoints are exposed under:
- /\_synapse/room_service/is_admin
- /\_synapse/room_service/changevisibility
- /\_synapse/room_service/getvisibility
- /\_synapse/room_service/changeprice
- /\_synapse/room_service/changeaccesstype
- /\_synapse/room_service/deleteroom

---

### Discover rooms
### Discover rooms and spaces

GET /discover

Returns a list of visible rooms with metadata.
Returns a list of visible rooms and spaces with metadata.

**Response fields:**

- `room_id`
- `name`
- `room_kind`
- `room_kind` — `"group"` (normal room) or `"space"`
- `access_type`
- `price`
- `keyword`
- `member_count`

Only rooms where the **admin user is a member** are returned.
Only rooms where the **admin user is a member** are returned. The frontend can
use `room_kind` to list spaces separately from rooms (e.g. under a "Public
spaces" filter).

---

Expand All @@ -96,22 +115,32 @@ Joins a user to a room using a keyword.

---

### Create room
### Create room or space

POST /create

Creates a new room with business metadata.
Creates a new room (or space) with business metadata.

**Requirements:**

- requester must be a Synapse admin
- `keyword` must be unique

**Body fields:**

- `name`
- `keyword` (unique)
- `visible`
- `access_type` (`public` / `private`)
- `price`
- `room_kind` (optional, default `"group"`) — pass `"space"` to create a space

**Behavior:**

- creates the room
- creates the room, or a space when `room_kind` is `"space"` (adds
`creation_content: { type: "m.space" }`)
- sets initial power levels
- stores metadata in the database
- stores metadata in the database (including `room_kind`)
- ensures the admin user joins the room
- configures join rules based on access type

Expand All @@ -129,6 +158,9 @@ Updates the visibility and price of a room.
- private visible rooms require a price
- public rooms cannot have a price

Note: spaces are always public/free, so their visibility is toggled with
`price = 0` and never hits the "private visible requires price" rule.

---

### Get room visibility
Expand Down Expand Up @@ -181,21 +213,23 @@ Admin-only operation.

---

### Delete room
### Delete room or space

POST /deleteroom

Deletes a room safely.
Deletes a room or space safely.

**Process:**

1. validates admin permissions
2. sends `m.room.tombstone`
3. kicks all members
4. removes business metadata
4. removes business metadata (and any bundle references)
5. requester leaves the room last

The room is marked as deleted but remains in Synapse history.
The room is marked as deleted but remains in Synapse history. Deleting a space
does not delete the rooms inside it (they simply lose that organisational
parent), which is standard Matrix behaviour.

---

Expand All @@ -207,9 +241,11 @@ The module stores additional room metadata, including:
- `visible`
- `access_type`
- `price`
- `room_kind`
- `room_kind` — `"group"` or `"space"`

This data is persisted using custom database helpers.
This data is persisted using custom database helpers. `get_visible_rooms`
returns rows where `room_kind IN ('group', 'space')`, so both appear in
discover.

---

Expand All @@ -222,6 +258,8 @@ modules:
- module: modules.room_service.module.RoomServiceModule
config:
admin_user_id: "@admin:example.com"
admin_token: "SYNAPSE_ADMIN_TOKEN"
admin_token_file: "/path/to/admin_token.txt"
homeserver: "https://matrix.example.com"
```

Keep the admin token file out of version control (add it to `.gitignore`).
16 changes: 5 additions & 11 deletions modules/room_service/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,8 @@ def __init__(self, config, api: ModuleApi):

self.hs.room_service = service

api.register_third_party_rules_callbacks(
check_event_allowed=self.check_event_allowed,
)

# Spaces sao permitidos. O hook check_event_allowed abaixo permite
# todos os eventos; mantido (e nao registrado) para regras futuras.

api.register_web_resource(
"/_synapse/room_service/discover",
Expand Down Expand Up @@ -100,10 +98,6 @@ async def check_event_allowed(
event,
state_events,
):
if event.type == "m.room.create":
room_type = event.content.get("type")

if room_type == "m.space":
raise SynapseError(403, "Spaces não são permitidos.")

return True, None
# Todos os eventos permitidos (spaces incluidos).
# Ponto de extensao para regras futuras de moderacao.
return True, None
20 changes: 16 additions & 4 deletions modules/room_service/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ async def create_room(self, requester, data):

join_rule = "public"

# room_kind decide se criamos um group (room normal) ou um space.
# get_visible_rooms ja aceita ('group', 'space'), entao um space
# visivel aparece no discover pelo mesmo caminho da room.
room_kind = data.get("room_kind", "group")
is_space = room_kind == "space"

room_config = {
"name": data["name"],
"is_direct": False,
Expand Down Expand Up @@ -265,8 +271,14 @@ async def create_room(self, requester, data):
},
],
}
# marca o room como space na criacao (o que o check_event_allowed
# barrava antes). Sem isso o Matrix cria uma room normal.
if is_space:
room_config["creation_content"] = {"type": "m.space"}

logger.info(
"create_room: creating room name='%s' creator=%s",
"create_room: creating %s name='%s' creator=%s",
"space" if is_space else "room",
data.get("name"),
creator,
)
Expand All @@ -286,6 +298,8 @@ async def create_room(self, requester, data):
room_id,
{
**data,
"room_kind": room_kind,
"access_type": access_type,
"price": price,
"visible": visible,
},
Expand Down Expand Up @@ -914,6 +928,4 @@ async def _leave_room(self, room_id, requester):
room_id=room_id,
action="leave",
ratelimit=False,
)


)
16 changes: 9 additions & 7 deletions modules/room_service/tests/test_check_event_allowed.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@


@pytest.fixture
def module():
def module(tmp_path):
api = Mock()
# o module.py le admin_token_file, entao criamos um arquivo temporario
token_file = tmp_path / "admin_token.txt"
token_file.write_text("token")
config = {
"admin_user_id": "@admin:test",
"admin_token": "token",
"admin_token_file": str(token_file),
"homeserver": "http://localhost",
}

Expand All @@ -20,18 +23,17 @@ def module():
# ---------------- CHECK EVENT ALLOWED ----------------

@pytest.mark.asyncio
async def test_block_space_creation(module):
async def test_allow_space_creation(module):
"""
Should reject creation of spaces (m.space).
Spaces are now allowed (previously blocked with 403).
"""
event = Mock()
event.type = "m.room.create"
event.content = {"type": "m.space"}

with pytest.raises(SynapseError) as err:
await module.check_event_allowed(event, state_events={})
allowed, _ = await module.check_event_allowed(event, state_events={})

assert err.value.code == 403
assert allowed is True


@pytest.mark.asyncio
Expand Down
92 changes: 92 additions & 0 deletions modules/room_service/tests/test_create_space.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import pytest
from unittest.mock import MagicMock, AsyncMock

from room_service.service import RoomService


def _make_service():
api = MagicMock()
hs = MagicMock()
api._hs = hs

# admin check passa
api.is_user_admin = AsyncMock(return_value=True)
# create_room devolve (room_id, _)
api.create_room = AsyncMock(return_value=("!new:localhost", None))

store = MagicMock()

async def fake_run(desc, func, *args):
if desc == "keyword_exists":
return False
# save_room_metadata e afins: no-op
return None

store.db_pool.runInteraction = AsyncMock(side_effect=fake_run)
hs.get_datastores.return_value.main = store

service = RoomService(
api=api,
admin_user_id="@admin:localhost",
admin_token="token",
homeserver="http://localhost:3000",
)
service.store = store
# neutraliza efeitos colaterais que batem no Matrix real
service._admin_join = AsyncMock()
service._admin_send_state = AsyncMock()
return service, api


def _requester(user_id="@creator:localhost"):
r = MagicMock()
r.user.to_string.return_value = user_id
return r


@pytest.mark.asyncio
async def test_create_room_normal_nao_e_space():
"""
Sem room_kind=space, o config nao deve marcar creation_content m.space.
"""
service, api = _make_service()

await service.create_room(
requester=_requester(),
data={"keyword": "k1", "name": "Sala", "room_kind": "group"},
)

config = api.create_room.await_args.kwargs["config"]
assert "creation_content" not in config


@pytest.mark.asyncio
async def test_create_room_space_marca_creation_content():
"""
Com room_kind=space, o config deve conter creation_content type m.space.
"""
service, api = _make_service()

await service.create_room(
requester=_requester(),
data={"keyword": "k2", "name": "Espaco", "room_kind": "space"},
)

config = api.create_room.await_args.kwargs["config"]
assert config.get("creation_content", {}).get("type") == "m.space"


@pytest.mark.asyncio
async def test_create_room_default_e_group():
"""
Sem room_kind no request, assume group (nao space).
"""
service, api = _make_service()

await service.create_room(
requester=_requester(),
data={"keyword": "k3", "name": "Sala"},
)

config = api.create_room.await_args.kwargs["config"]
assert "creation_content" not in config