about summary refs log tree commit diff
path: root/Tests
diff options
context:
space:
mode:
Diffstat (limited to 'Tests')
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/.gitignore1
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs40
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs2
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs103
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs56
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs34
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs58
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs106
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs130
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs119
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs81
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs58
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs52
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs (renamed from Tests/LibMatrix.HomeserverEmulator/Controllers/UserController.cs)43
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs44
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs18
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj4
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Program.cs25
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json63
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs57
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs47
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs103
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs12
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs197
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json4
25 files changed, 1330 insertions, 127 deletions
diff --git a/Tests/LibMatrix.HomeserverEmulator/.gitignore b/Tests/LibMatrix.HomeserverEmulator/.gitignore
new file mode 100644
index 0000000..8fce603
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/.gitignore
@@ -0,0 +1 @@
+data/
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs
index d0496bf..da56ec4 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs
@@ -8,16 +8,42 @@ namespace LibMatrix.HomeserverEmulator.Controllers;
 

 [ApiController]

 [Route("/_matrix/client/{version}/")]

-public class AuthController(ILogger<AuthController> logger, UserStore userStore) : ControllerBase {

+public class AuthController(ILogger<AuthController> logger, UserStore userStore, TokenService tokenService) : ControllerBase {

     [HttpPost("login")]

     public async Task<LoginResponse> Login(LoginRequest request) {

-        var user = await userStore.CreateUser($"@{Guid.NewGuid().ToString()}:{Request.Host}", Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new Dictionary<string, object>());

-        var loginResponse = new LoginResponse {

-            AccessToken = user.AccessToken,

-            DeviceId = user.DeviceId,

-            UserId = user.UserId

+        if(!request.Identifier.User.StartsWith('@'))

+            request.Identifier.User = $"@{request.Identifier.User}:{tokenService.GenerateServerName(HttpContext)}";

+        if(request.Identifier.User.EndsWith("localhost"))

+            request.Identifier.User = request.Identifier.User.Replace("localhost", tokenService.GenerateServerName(HttpContext));

+        

+        var user = await userStore.GetUserById(request.Identifier.User);

+        if(user is null) {

+            user = await userStore.CreateUser(request.Identifier.User);

+        }

+

+        return user.Login();

+    }

+

+    [HttpGet("login")]

+    public async Task<LoginFlowsResponse> GetLoginFlows() {

+        return new LoginFlowsResponse {

+            Flows = ((string[]) [

+                "m.login.password",

+                "m.login.recaptcha",

+                "m.login.sso",

+                "m.login.email.identity",

+                "m.login.msisdn",

+                "m.login.dummy",

+                "m.login.registration_token",

+            ]).Select(x => new LoginFlowsResponse.LoginFlow { Type = x }).ToList()

         };

+    }

+}

+

+public class LoginFlowsResponse {

+    public required List<LoginFlow> Flows { get; set; }

 

-        return loginResponse;

+    public class LoginFlow {

+        public required string Type { get; set; }

     }

 }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs
index 0c4d8bd..9e0c17c 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs
@@ -8,7 +8,7 @@ namespace LibMatrix.HomeserverEmulator.Controllers;
 public class HEDebugController(ILogger<HEDebugController> logger, UserStore userStore, RoomStore roomStore) : ControllerBase {

     [HttpGet("users")]

     public async Task<List<UserStore.User>> GetUsers() {

-        return userStore._users;

+        return userStore._users.ToList();

     }

     

     [HttpGet("rooms")]

diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs
new file mode 100644
index 0000000..7898a8c
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/KeysController.cs
@@ -0,0 +1,103 @@
+// using System.Security.Cryptography;
+// using System.Text.Json.Nodes;
+// using System.Text.Json.Serialization;
+// using LibMatrix.HomeserverEmulator.Services;
+// using LibMatrix.Responses;
+// using LibMatrix.Services;
+// using Microsoft.AspNetCore.Mvc;
+//
+// namespace LibMatrix.HomeserverEmulator.Controllers;
+//
+// [ApiController]
+// [Route("/_matrix/client/{version}/")]
+// public class KeysController(ILogger<KeysController> logger, TokenService tokenService, UserStore userStore) : ControllerBase {
+//     [HttpGet("room_keys/version")]
+//     public async Task<RoomKeysResponse> GetRoomKeys() {
+//         var token = tokenService.GetAccessToken(HttpContext);
+//         if (token == null)
+//             throw new MatrixException() {
+//                 ErrorCode = "M_MISSING_TOKEN",
+//                 Error = "Missing token"
+//             };
+//
+//         var user = await userStore.GetUserByToken(token);
+//         if (user == null)
+//             throw new MatrixException() {
+//                 ErrorCode = "M_UNKNOWN_TOKEN",
+//                 Error = "No such user"
+//             };
+//
+//         if (user.RoomKeys is not { Count: > 0 })
+//             throw new MatrixException() {
+//                 ErrorCode = "M_NOT_FOUND",
+//                 Error = "No keys found"
+//             };
+//
+//         return user.RoomKeys.Values.Last();
+//     }
+//
+//     [HttpPost("room_keys/version")]
+//     public async Task<RoomKeysResponse> UploadRoomKeys(RoomKeysRequest request) {
+//         var token = tokenService.GetAccessToken(HttpContext);
+//         if (token == null)
+//             throw new MatrixException() {
+//                 ErrorCode = "M_MISSING_TOKEN",
+//                 Error = "Missing token"
+//             };
+//
+//         var user = await userStore.GetUserByToken(token);
+//         if (user == null)
+//             throw new MatrixException() {
+//                 ErrorCode = "M_UNKNOWN_TOKEN",
+//                 Error = "No such user"
+//             };
+//
+//         var roomKeys = new RoomKeysResponse {
+//             Version = Guid.NewGuid().ToString(),
+//             Etag = Guid.NewGuid().ToString(),
+//             Algorithm = request.Algorithm,
+//             AuthData = request.AuthData
+//         };
+//         user.RoomKeys.Add(roomKeys.Version, roomKeys);
+//         return roomKeys;
+//     }
+//     
+//     [HttpPost("keys/device_signing/upload")]
+//     public async Task<object> UploadDeviceSigning(JsonObject request) {
+//         var token = tokenService.GetAccessToken(HttpContext);
+//         if (token == null)
+//             throw new MatrixException() {
+//                 ErrorCode = "M_MISSING_TOKEN",
+//                 Error = "Missing token"
+//             };
+//
+//         var user = await userStore.GetUserByToken(token);
+//         if (user == null)
+//             throw new MatrixException() {
+//                 ErrorCode = "M_UNKNOWN_TOKEN",
+//                 Error = "No such user"
+//             };
+//
+//         return new { };
+//     }
+// }
+//
+// public class DeviceSigningRequest {
+//     public CrossSigningKey? MasterKey { get; set; }
+//     public CrossSigningKey? SelfSigningKey { get; set; }
+//     public CrossSigningKey? UserSigningKey { get; set; }
+//     
+//     public class CrossSigningKey {
+//         [JsonPropertyName("keys")]
+//         public Dictionary<string, string> Keys { get; set; }
+//         
+//         [JsonPropertyName("signatures")]
+//         public Dictionary<string, Dictionary<string, string>> Signatures { get; set; }
+//         
+//         [JsonPropertyName("usage")]
+//         public List<string> Usage { get; set; }
+//         
+//         [JsonPropertyName("user_id")]
+//         public string UserId { get; set; }
+//     }
+// }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs
new file mode 100644
index 0000000..e3f781b
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/LegacyController.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.HomeserverEmulator.Services;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LibMatrix.HomeserverEmulator.Controllers;
+
+[ApiController]
+[Route("/_matrix/client/{version}/")]
+public class LegacyController(ILogger<LegacyController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {
+    [HttpGet("rooms/{roomId}/initialSync")]
+    [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Endpoint is expected to wait until data is available or timeout.")]
+    public async Task<object> Sync([FromRoute] string roomId, [FromQuery] int limit = 20) {
+        var sw = Stopwatch.StartNew();
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        var room = roomStore.GetRoomById(roomId);
+        if (room is null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Room not found."
+            };
+        var accountData = room.AccountData.GetOrCreate(user.UserId, _ => []);
+        var membership = room.State.FirstOrDefault(x => x.Type == "m.room.member" && x.StateKey == user.UserId);
+        var timelineChunk = room.Timeline.TakeLast(limit).ToList();
+        return new {
+            account_data = accountData,
+            membership = (membership?.TypedContent as RoomMemberEventContent)?.Membership ?? "leave",
+            room_id = room.RoomId,
+            state = room.State.ToList(),
+            visibility = "public",
+            messages = new PaginatedChunkedStateEventResponse() {
+                Chunk = timelineChunk,
+                End = timelineChunk.Last().EventId,
+                Start = timelineChunk.Count >= limit ? timelineChunk.First().EventId : null
+            }
+        };
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs
new file mode 100644
index 0000000..dba36d7
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Media/MediaController.cs
@@ -0,0 +1,34 @@
+using LibMatrix.HomeserverEmulator.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LibMatrix.HomeserverEmulator.Controllers.Media;
+
+[ApiController]
+[Route("/_matrix/media/{version}/")]
+public class MediaController(ILogger<MediaController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {
+    [HttpPost("upload")]
+    public async Task<object> UploadMedia([FromHeader(Name = "Content-Type")] string ContentType, [FromQuery] string filename, [FromBody] Stream file) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        
+        
+        
+        var mediaId = Guid.NewGuid().ToString();
+        var media = new {
+            content_uri = $"mxc://{tokenService.GenerateServerName(HttpContext)}/{mediaId}"
+        };
+        return media;
+        
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs
new file mode 100644
index 0000000..d5f4217
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomMembersController.cs
@@ -0,0 +1,58 @@
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.HomeserverEmulator.Services;
+using LibMatrix.Responses;
+using LibMatrix.RoomTypes;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.OpenApi.Validations.Rules;
+
+namespace LibMatrix.HomeserverEmulator.Controllers.Rooms;
+
+[ApiController]
+[Route("/_matrix/client/{version}/rooms/{roomId}/")]
+public class RoomMembersController(ILogger<RoomMembersController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {
+    [HttpGet("members")]
+    public async Task<List<StateEventResponse>> CreateRoom(string roomId, string? at = null, string? membership = null, string? not_membership = null) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        
+        var room = roomStore.GetRoomById(roomId);
+        if (room == null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Room not found"
+            };
+        
+        var members = room.State.Where(x => x.Type == "m.room.member").ToList();
+        
+        if(membership != null)
+            members = members.Where(x => (x.TypedContent as RoomMemberEventContent)?.Membership == membership).ToList();
+        
+        if(not_membership != null)
+            members = members.Where(x => (x.TypedContent as RoomMemberEventContent)?.Membership != not_membership).ToList();
+
+        if (at != null) {
+            var evt = room.Timeline.FirstOrDefault(x => x.EventId == at);
+            if (evt == null)
+                throw new MatrixException() {
+                    ErrorCode = "M_NOT_FOUND",
+                    Error = "Event not found"
+                };
+            
+            members = members.Where(x => x.OriginServerTs <= evt.OriginServerTs).ToList();
+        }
+
+        return members;
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs
new file mode 100644
index 0000000..593f5b0
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs
@@ -0,0 +1,106 @@
+using System.Collections.Frozen;
+using LibMatrix.HomeserverEmulator.Extensions;
+using LibMatrix.HomeserverEmulator.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LibMatrix.HomeserverEmulator.Controllers.Rooms;
+
+[ApiController]
+[Route("/_matrix/client/{version}/rooms/{roomId}/state")]
+public class RoomStateController(ILogger<RoomStateController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {
+    [HttpGet("")]
+    public async Task<FrozenSet<StateEventResponse>> GetState(string roomId) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        
+        var room = roomStore.GetRoomById(roomId);
+        if (room == null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Room not found"
+            };
+
+        return room.State;
+    }
+    
+    [HttpGet("{eventType}")]
+    public async Task<StateEventResponse> GetState(string roomId, string eventType) {
+        return await GetState(roomId, eventType, "");
+    }
+    
+    [HttpGet("{eventType}/{stateKey}")]
+    public async Task<StateEventResponse> GetState(string roomId, string eventType, string stateKey) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        
+        var room = roomStore.GetRoomById(roomId);
+        if (room == null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Room not found"
+            };
+
+        var stateEvent = room.State.FirstOrDefault(x => x.Type == eventType && x.StateKey == stateKey);
+        if (stateEvent == null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Event not found"
+            };
+        return stateEvent;
+    }
+        
+    [HttpPut("{eventType}")]
+    public async Task<EventIdResponse> SetState(string roomId, string eventType, [FromBody] StateEvent request) {
+        return await SetState(roomId, eventType, "", request);
+    }
+    
+    [HttpPut("{eventType}/{stateKey}")]
+    public async Task<EventIdResponse> SetState(string roomId, string eventType, string stateKey, [FromBody] StateEvent request) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        
+        var room = roomStore.GetRoomById(roomId);
+        if (room == null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Room not found"
+            };
+        var evt = room.SetStateInternal(request.ToStateEvent(user, room));
+        evt.Type = eventType;
+        evt.StateKey = stateKey;
+        return new EventIdResponse(evt);
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs
new file mode 100644
index 0000000..e9f52dc
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomsController.cs
@@ -0,0 +1,130 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.HomeserverEmulator.Services;
+using LibMatrix.Responses;
+using LibMatrix.RoomTypes;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LibMatrix.HomeserverEmulator.Controllers.Rooms;
+
+[ApiController]
+[Route("/_matrix/client/{version}/")]
+public class RoomsController(ILogger<RoomsController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {
+    //createRoom
+    [HttpPost("createRoom")]
+    public async Task<RoomIdResponse> CreateRoom([FromBody] CreateRoomRequest request) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+
+        var room = new RoomStore.Room($"!{Guid.NewGuid()}:{tokenService.GenerateServerName(HttpContext)}");
+        var createEvent = room.SetStateInternal(new() {
+            Type = RoomCreateEventContent.EventId,
+            RawContent = new() {
+                ["creator"] = user.UserId
+            }
+        });
+        foreach (var (key, value) in request.CreationContent) {
+            createEvent.RawContent[key] = value.DeepClone();
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.Name))
+            room.SetStateInternal(new StateEvent() {
+                Type = RoomNameEventContent.EventId,
+                TypedContent = new RoomNameEventContent() {
+                    Name = request.Name
+                }
+            });
+
+        if (!string.IsNullOrWhiteSpace(request.RoomAliasName))
+            room.SetStateInternal(new StateEvent() {
+                Type = RoomCanonicalAliasEventContent.EventId,
+                TypedContent = new RoomCanonicalAliasEventContent() {
+                    Alias = $"#{request.RoomAliasName}:localhost"
+                }
+            });
+
+        if (request.InitialState is { Count: > 0 }) {
+            foreach (var stateEvent in request.InitialState) {
+                room.SetStateInternal(stateEvent);
+            }
+        }
+
+        room.AddUser(user.UserId);
+
+        // user.Rooms.Add(room.RoomId, room);
+        return new() {
+            RoomId = room.RoomId
+        };
+    }
+
+    [HttpPost("rooms/{roomId}/upgrade")]
+    public async Task<object> UpgradeRoom(string roomId, [FromBody] UpgradeRoomRequest request) {
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+
+        var oldRoom = roomStore.GetRoomById(roomId);
+        if (oldRoom == null)
+            throw new MatrixException() {
+                ErrorCode = "M_NOT_FOUND",
+                Error = "Room not found"
+            };
+
+        var room = new RoomStore.Room($"!{Guid.NewGuid()}:{tokenService.GenerateServerName(HttpContext)}");
+
+        var eventTypesToTransfer = new[] {
+            RoomServerACLEventContent.EventId,
+            RoomEncryptionEventContent.EventId,
+            RoomNameEventContent.EventId,
+            RoomAvatarEventContent.EventId,
+            RoomTopicEventContent.EventId,
+            RoomGuestAccessEventContent.EventId,
+            RoomHistoryVisibilityEventContent.EventId,
+            RoomJoinRulesEventContent.EventId,
+            RoomPowerLevelEventContent.EventId,
+        };
+
+        var createEvent = room.SetStateInternal(new() {
+            Type = RoomCreateEventContent.EventId,
+            RawContent = new() {
+                ["creator"] = user.UserId
+            }
+        });
+        
+        oldRoom.State.Where(x => eventTypesToTransfer.Contains(x.Type)).ToList().ForEach(x => room.SetStateInternal(x));
+
+        room.AddUser(user.UserId);
+
+        // user.Rooms.Add(room.RoomId, room);
+        return new {
+            replacement_room = room.RoomId
+        };
+    }
+}
+
+public class UpgradeRoomRequest {
+    [JsonPropertyName("new_version")]
+    public required string NewVersion { get; set; }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs
new file mode 100644
index 0000000..1653110
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/SyncController.cs
@@ -0,0 +1,119 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+using ArcaneLibs.Extensions;
+using LibMatrix.HomeserverEmulator.Services;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LibMatrix.HomeserverEmulator.Controllers;
+
+[ApiController]
+[Route("/_matrix/client/{version}/")]
+public class SyncController(ILogger<SyncController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore, HSEConfiguration cfg) : ControllerBase {
+    [HttpGet("sync")]
+    [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Endpoint is expected to wait until data is available or timeout.")]
+    public async Task<SyncResponse> Sync([FromQuery] string? since = null, [FromQuery] int? timeout = 5000) {
+        var sw = Stopwatch.StartNew();
+        var token = tokenService.GetAccessToken(HttpContext);
+        if (token == null)
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing token"
+            };
+
+        var user = await userStore.GetUserByToken(token);
+        if (user == null)
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN_TOKEN",
+                Error = "No such user"
+            };
+        var session = user.AccessTokens[token];
+
+        if (string.IsNullOrWhiteSpace(since))
+            return InitialSync(user, session);
+
+        if (!session.SyncStates.TryGetValue(since, out var syncState))
+            if (!cfg.UnknownSyncTokenIsInitialSync)
+                throw new MatrixException() {
+                    ErrorCode = "M_UNKNOWN",
+                    Error = "Unknown sync token."
+                };
+            else
+                return InitialSync(user, session);
+
+        var response = new SyncResponse() {
+            NextBatch = Guid.NewGuid().ToString(),
+            DeviceOneTimeKeysCount = new()
+        };
+
+        session.SyncStates.Add(response.NextBatch, new() {
+            RoomPositions = syncState.RoomPositions.ToDictionary(x => x.Key, x => new UserStore.User.SessionInfo.UserSyncState.SyncRoomPosition() {
+                StatePosition = roomStore._rooms.First(y => y.RoomId == x.Key).State.Count,
+                TimelinePosition = roomStore._rooms.First(y => y.RoomId == x.Key).Timeline.Count,
+                AccountDataPosition = roomStore._rooms.First(y => y.RoomId == x.Key).AccountData[user.UserId].Count
+            })
+        });
+
+        if (!string.IsNullOrWhiteSpace(since)) {
+            while (sw.ElapsedMilliseconds < timeout && response.Rooms?.Join is not { Count: > 0 }) {
+                await Task.Delay(100);
+                var rooms = roomStore._rooms.Where(x => x.State.Any(y => y.Type == "m.room.member" && y.StateKey == user.UserId)).ToList();
+                foreach (var room in rooms) {
+                    var roomPositions = syncState.RoomPositions[room.RoomId];
+
+                    response.Rooms ??= new();
+                    response.Rooms.Join ??= new();
+                    response.Rooms.Join[room.RoomId] = new() {
+                        State = new(room.State.Skip(roomPositions.StatePosition).ToList()),
+                        Timeline = new(events: room.Timeline.Skip(roomPositions.TimelinePosition).ToList(), limited: false),
+                        AccountData = new(room.AccountData.GetOrCreate(user.UserId, _ => []).Skip(roomPositions.AccountDataPosition).ToList())
+                    };
+                    session.SyncStates[response.NextBatch].RoomPositions[room.RoomId] = new() {
+                        StatePosition = room.State.Count,
+                        TimelinePosition = room.Timeline.Count,
+                        AccountDataPosition = room.AccountData[user.UserId].Count
+                    };
+
+                    if (response.Rooms.Join[room.RoomId].State.Events.Count == 0 &&
+                        response.Rooms.Join[room.RoomId].Timeline.Events.Count == 0 &&
+                        response.Rooms.Join[room.RoomId].AccountData.Events.Count == 0
+                       )
+                        response.Rooms.Join.Remove(room.RoomId);
+                }
+            }
+        }
+
+        return response;
+    }
+
+    private SyncResponse InitialSync(UserStore.User user, UserStore.User.SessionInfo session) {
+        var response = new SyncResponse() {
+            NextBatch = Guid.NewGuid().ToString(),
+            DeviceOneTimeKeysCount = new(),
+            AccountData = new(events: user.AccountData.ToList())
+        };
+
+        session.SyncStates.Add(response.NextBatch, new());
+
+        var rooms = roomStore._rooms.Where(x => x.State.Any(y => y.Type == "m.room.member" && y.StateKey == user.UserId)).ToList();
+        foreach (var room in rooms) {
+            response.Rooms ??= new();
+            response.Rooms.Join ??= new();
+            response.Rooms.Join[room.RoomId] = new() {
+                State = new(room.State.ToList()),
+                Timeline = new(events: room.Timeline.ToList(), limited: false),
+                AccountData = new(room.AccountData.GetOrCreate(user.UserId, _ => []).ToList())
+            };
+            session.SyncStates[response.NextBatch].RoomPositions[room.RoomId] = new() {
+                StatePosition = room.State.Count,
+                TimelinePosition = room.Timeline.Count,
+                AccountDataPosition = room.AccountData[user.UserId].Count
+            };
+        }
+
+        return response;
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs
new file mode 100644
index 0000000..8cd5c75
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/AccountDataController.cs
@@ -0,0 +1,81 @@
+using System.Text.Json.Nodes;

+using ArcaneLibs.Extensions;

+using LibMatrix.EventTypes.Spec.State;

+using LibMatrix.Filters;

+using LibMatrix.HomeserverEmulator.Services;

+using LibMatrix.Responses;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

+[Route("/_matrix/client/{version}/")]

+public class AccountDataController(ILogger<AccountDataController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {

+    [HttpGet("user/{mxid}/account_data/{type}")]

+    public async Task<object> GetAccountData(string type) {

+        var token = tokenService.GetAccessToken(HttpContext);

+        if (token is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "No token passed."

+            };

+

+        var user = await userStore.GetUserByToken(token, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "Invalid token."

+            };

+        var value = user.AccountData.FirstOrDefault(x=>x.Type == type);

+        if (value is null)

+            throw new MatrixException() {

+                ErrorCode = "M_NOT_FOUND",

+                Error = "Key not found."

+            };

+        return value;

+    }

+    

+    [HttpPut("user/{mxid}/account_data/{type}")]

+    public async Task<object> SetAccountData(string type, [FromBody] JsonObject data) {

+        var token = tokenService.GetAccessToken(HttpContext);

+        if (token is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "No token passed."

+            };

+

+        var user = await userStore.GetUserByToken(token, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "Invalid token."

+            };

+        

+        user.AccountData.Where(x=>x.Type == type).ToList().ForEach(response => user.AccountData.Remove(response));

+        

+        user.AccountData.Add(new() {

+            Type = type,

+            RawContent = data

+        });

+        return data;

+    }

+    

+    // specialised account data...

+    [HttpGet("pushrules")]

+    public async Task<object> GetPushRules() {

+        var token = tokenService.GetAccessToken(HttpContext);

+        if (token is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "No token passed."

+            };

+

+        var user = await userStore.GetUserByToken(token, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "Invalid token."

+            };

+        return new { };

+    }

+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs
new file mode 100644
index 0000000..ecbccd4
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/FilterController.cs
@@ -0,0 +1,58 @@
+using System.Text.Json.Nodes;

+using ArcaneLibs.Extensions;

+using LibMatrix.EventTypes.Spec.State;

+using LibMatrix.Filters;

+using LibMatrix.HomeserverEmulator.Services;

+using LibMatrix.Responses;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

+[Route("/_matrix/client/{version}/")]

+public class FilterController(ILogger<FilterController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {

+    [HttpPost("user/{mxid}/filter")]

+    public async Task<object> CreateFilter(string mxid, [FromBody] SyncFilter filter) {

+        var token = tokenService.GetAccessToken(HttpContext);

+        if (token is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "No token passed."

+            };

+

+        var user = await userStore.GetUserByToken(token, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "Invalid token."

+            };

+        var filterId = Guid.NewGuid().ToString();

+        user.Filters[filterId] = filter;

+        return new {

+            filter_id = filterId

+        };

+    }

+    

+    [HttpGet("user/{mxid}/filter/{filterId}")]

+    public async Task<SyncFilter> GetFilter(string mxid, string filterId) {

+        var token = tokenService.GetAccessToken(HttpContext);

+        if (token is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "No token passed."

+            };

+

+        var user = await userStore.GetUserByToken(token, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNAUTHORIZED",

+                Error = "Invalid token."

+            };

+        if (!user.Filters.ContainsKey(filterId))

+            throw new MatrixException() {

+                ErrorCode = "M_NOT_FOUND",

+                Error = "Filter not found."

+            };

+        return user.Filters[filterId];

+    }

+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs
new file mode 100644
index 0000000..c717ba5
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/ProfileController.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Nodes;

+using ArcaneLibs.Extensions;

+using LibMatrix.EventTypes.Spec.State;

+using LibMatrix.Filters;

+using LibMatrix.HomeserverEmulator.Services;

+using LibMatrix.Responses;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

+[Route("/_matrix/client/{version}/")]

+public class ProfileController(ILogger<ProfileController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {

+    [HttpGet("profile/{userId}")]

+    public async Task<IDictionary<string, object>> GetProfile(string userId) {

+        var user = await userStore.GetUserById(userId, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_NOT_FOUND",

+                Error = "User not found."

+            };

+        return user.Profile;

+    }

+

+    [HttpGet("profile/{userId}/{key}")]

+    public async Task<object> GetProfile(string userId, string key) {

+        var user = await userStore.GetUserById(userId, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_NOT_FOUND",

+                Error = "User not found."

+            };

+        if (!user.Profile.TryGetValue(key, out var value))

+            throw new MatrixException() {

+                ErrorCode = "M_NOT_FOUND",

+                Error = "Key not found."

+            };

+        return value;

+    }

+

+    [HttpPut("profile/{userId}/{key}")]

+    public async Task<object> SetProfile(string userId, string key, [FromBody] JsonNode value) {

+        var user = await userStore.GetUserById(userId, false);

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_NOT_FOUND",

+                Error = "User not found."

+            };

+        user.Profile[key] = value[key];

+        return value;

+    }

+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/UserController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs
index d763b26..eb2b879 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Controllers/UserController.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs
@@ -1,5 +1,7 @@
 using System.Text.Json.Nodes;

 using ArcaneLibs.Extensions;

+using LibMatrix.EventTypes.Spec.State;

+using LibMatrix.Filters;

 using LibMatrix.HomeserverEmulator.Services;

 using LibMatrix.Responses;

 using Microsoft.AspNetCore.Mvc;

@@ -8,17 +10,17 @@ namespace LibMatrix.HomeserverEmulator.Controllers;
 

 [ApiController]

 [Route("/_matrix/client/{version}/")]

-public class UserController(ILogger<UserController> logger, TokenService tokenService, UserStore userStore) : ControllerBase {

+public class UserController(ILogger<UserController> logger, TokenService tokenService, UserStore userStore, RoomStore roomStore) : ControllerBase {

     [HttpGet("account/whoami")]

     public async Task<WhoAmIResponse> Login() {

-        var token = tokenService.GetAccessToken();

+        var token = tokenService.GetAccessToken(HttpContext);

         if (token is null)

             throw new MatrixException() {

                 ErrorCode = "M_UNAUTHORIZED",

                 Error = "No token passed."

             };

 

-        var user = await userStore.GetUserByToken(token, Random.Shared.Next(101) <= 10, tokenService.GenerateServerName());

+        var user = await userStore.GetUserByToken(token, Random.Shared.Next(101) <= 10, tokenService.GenerateServerName(HttpContext));

         if (user is null)

             throw new MatrixException() {

                 ErrorCode = "M_UNKNOWN_TOKEN",

@@ -29,37 +31,10 @@ public class UserController(ILogger<UserController> logger, TokenService tokenSe
         };

         return whoAmIResponse;

     }

-    

-    [HttpGet("profile/{userId}")]

-    public async Task<Dictionary<string, object>> GetProfile(string userId) {

-        var user = await userStore.GetUserById(userId, false);

-        if (user is null)

-            throw new MatrixException() {

-                ErrorCode = "M_NOT_FOUND",

-                Error = "User not found."

-            };

-        return user.Profile;

-    }

-    

-    [HttpGet("profile/{userId}/{key}")]

-    public async Task<object> GetProfile(string userId, string key) {

-        var user = await userStore.GetUserById(userId, false);

-        if (user is null)

-            throw new MatrixException() {

-                ErrorCode = "M_NOT_FOUND",

-                Error = "User not found."

-            };

-        if (!user.Profile.TryGetValue(key, out var value))

-            throw new MatrixException() {

-                ErrorCode = "M_NOT_FOUND",

-                Error = "Key not found."

-            };

-        return value;

-    }

-    

+

     [HttpGet("joined_rooms")]

     public async Task<object> GetJoinedRooms() {

-        var token = tokenService.GetAccessToken();

+        var token = tokenService.GetAccessToken(HttpContext);

         if (token is null)

             throw new MatrixException() {

                 ErrorCode = "M_UNAUTHORIZED",

@@ -75,7 +50,9 @@ public class UserController(ILogger<UserController> logger, TokenService tokenSe
         // return user.JoinedRooms;

 

         return new {

-            joined_rooms = user.JoinedRooms

+            joined_rooms = roomStore._rooms.Where(r =>

+                r.State.Any(s => s.StateKey == user.UserId && s.Type == RoomMemberEventContent.EventId && (s.TypedContent as RoomMemberEventContent).Membership == "join")

+            ).Select(r => r.RoomId).ToList()

         };

     }

 }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs
index 1349fac..704e26b 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs
@@ -1,4 +1,5 @@
 using System.Text.Json.Nodes;

+using System.Text.Json.Serialization;

 using LibMatrix.Homeservers;

 using LibMatrix.Responses;

 using LibMatrix.Services;

@@ -45,4 +46,47 @@ public class VersionsController(ILogger<WellKnownController> logger) : Controlle
         };

         return clientVersions;

     }

+

+    [HttpGet("client/{version}/capabilities")]

+    public async Task<CapabilitiesResponse> GetCapabilities() {

+        return new() {

+            Capabilities = new() {

+                ChangePassword = new() {

+                    Enabled = false

+                },

+                RoomVersions = new() {

+                    Default = "11",

+                    Available = new() {

+                        ["11"] = "unstable"

+                    }

+                }

+            }

+        };

+    }

+}

+

+public class CapabilitiesResponse {

+    [JsonPropertyName("capabilities")]

+    public CapabilitiesContent Capabilities { get; set; }

+

+    public class CapabilitiesContent {

+        [JsonPropertyName("m.room_versions")]

+        public RoomVersionsContent RoomVersions { get; set; }

+        

+        [JsonPropertyName("m.change_password")]

+        public ChangePasswordContent ChangePassword { get; set; }

+

+        public class ChangePasswordContent {

+            [JsonPropertyName("enabled")]

+            public bool Enabled { get; set; }

+        }

+

+        public class RoomVersionsContent {

+            [JsonPropertyName("default")]

+            public string Default { get; set; }

+

+            [JsonPropertyName("available")]

+            public Dictionary<string, string> Available { get; set; }

+        }

+    }

 }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs b/Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs
new file mode 100644
index 0000000..1d03d7a
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Extensions/EventExtensions.cs
@@ -0,0 +1,18 @@
+using LibMatrix.HomeserverEmulator.Services;
+
+namespace LibMatrix.HomeserverEmulator.Extensions;
+
+public static class EventExtensions {
+    public static StateEventResponse ToStateEvent(this StateEvent stateEvent, UserStore.User user, RoomStore.Room room) {
+        return new StateEventResponse {
+            RawContent = stateEvent.RawContent,
+            EventId = "$" + string.Join("", Random.Shared.GetItems("abcdefghijklmnopqrstuvwxyzABCDEFGHIJLKMNOPQRSTUVWXYZ0123456789".ToCharArray(), 100)),
+            RoomId = room.RoomId,
+            Sender = user.UserId,
+            StateKey = stateEvent.StateKey,
+            Type = stateEvent.Type,
+            OriginServerTs = DateTimeOffset.Now.ToUnixTimeMilliseconds()
+        };
+    }
+    
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj
index e6b4572..6588675 100644
--- a/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj
+++ b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj
@@ -18,4 +18,8 @@
   <ItemGroup>

     <ProjectReference Include="..\..\LibMatrix\LibMatrix.csproj" />

   </ItemGroup>

+

+  <ItemGroup>

+    <Folder Include="data\rooms\" />

+  </ItemGroup>

 </Project>

diff --git a/Tests/LibMatrix.HomeserverEmulator/Program.cs b/Tests/LibMatrix.HomeserverEmulator/Program.cs
index 516d380..ddf39c7 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Program.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Program.cs
@@ -1,16 +1,20 @@
 using System.Net.Mime;

+using System.Text.Json.Serialization;

 using LibMatrix;

 using LibMatrix.HomeserverEmulator.Services;

 using Microsoft.AspNetCore.Diagnostics;

 using Microsoft.AspNetCore.Http.Timeouts;

+using Microsoft.AspNetCore.Mvc;

 using Microsoft.OpenApi.Models;

 

 var builder = WebApplication.CreateBuilder(args);

 

 // Add services to the container.

 

-builder.Services.AddControllers();

-// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle

+builder.Services.AddControllers().AddJsonOptions(options => {

+    options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;

+});

+

 builder.Services.AddEndpointsApiExplorer();

 builder.Services.AddSwaggerGen(c => {

     c.SwaggerDoc("v1", new OpenApiInfo() {

@@ -20,11 +24,12 @@ builder.Services.AddSwaggerGen(c => {
     });

     c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, "LibMatrix.HomeserverEmulator.xml"));

 });

+

 builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

+builder.Services.AddSingleton<HSEConfiguration>();

 builder.Services.AddSingleton<UserStore>();

 builder.Services.AddSingleton<RoomStore>();

 

-

 builder.Services.AddScoped<TokenService>();

 

 builder.Services.AddRequestTimeouts(x => {

@@ -45,7 +50,7 @@ builder.Services.AddRequestTimeouts(x => {
 builder.Services.AddCors(options => {

     options.AddPolicy(

         "Open",

-        policy => policy.AllowAnyOrigin().AllowAnyHeader());

+        policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());

 });

 var app = builder.Build();

 

@@ -62,6 +67,8 @@ app.UseExceptionHandler(exceptionHandlerApp => {
 

         var exceptionHandlerPathFeature =

             context.Features.Get<IExceptionHandlerPathFeature>();

+        if(exceptionHandlerPathFeature?.Error is not null)

+            Console.WriteLine(exceptionHandlerPathFeature.Error.ToString()!);

 

         if (exceptionHandlerPathFeature?.Error is MatrixException mxe) {

             context.Response.StatusCode = mxe.ErrorCode switch {

@@ -87,4 +94,14 @@ app.UseAuthorization();
 

 app.MapControllers();

 

+app.Map("/_matrix/{*_}", (HttpContext ctx) => {

+    Console.WriteLine($"Client hit non-existing route: {ctx.Request.Method} {ctx.Request.Path}");

+    ctx.Response.StatusCode = StatusCodes.Status404NotFound;

+    ctx.Response.ContentType = MediaTypeNames.Application.Json;

+    return ctx.Response.WriteAsJsonAsync(new MatrixException() {

+        ErrorCode = MatrixException.ErrorCodes.M_UNRECOGNISED,

+        Error = "Endpoint not implemented"

+    });

+});

+

 app.Run();

diff --git a/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json b/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json
index 8ab6b3d..4ddf341 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json
+++ b/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json
@@ -1,31 +1,32 @@
-{

-  "$schema": "http://json.schemastore.org/launchsettings.json",

-  "iisSettings": {

-    "windowsAuthentication": false,

-    "anonymousAuthentication": true,

-    "iisExpress": {

-      "applicationUrl": "http://localhost:6824",

-      "sslPort": 0

-    }

-  },

-  "profiles": {

-    "http": {

-      "commandName": "Project",

-      "dotnetRunMessages": true,

-      "launchBrowser": true,

-      "launchUrl": "swagger",

-      "applicationUrl": "http://localhost:5298",

-      "environmentVariables": {

-        "ASPNETCORE_ENVIRONMENT": "Development"

-      }

-    },

-    "IIS Express": {

-      "commandName": "IISExpress",

-      "launchBrowser": true,

-      "launchUrl": "swagger",

-      "environmentVariables": {

-        "ASPNETCORE_ENVIRONMENT": "Development"

-      }

-    }

-  }

-}

+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:9169",
+      "sslPort": 44321
+    }
+  },
+  "profiles": {
+    "Development": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": false,
+      "launchUrl": "swagger",
+      "applicationUrl": "http://localhost:5298",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "Local": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": false,
+      "applicationUrl": "http://localhost:5298",
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Local"
+      }
+    }
+  }
+}
diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs b/Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs
new file mode 100644
index 0000000..73b0d23
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/HSEConfiguration.cs
@@ -0,0 +1,57 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.HomeserverEmulator.Services;
+
+public class HSEConfiguration {
+    private static ILogger<HSEConfiguration> _logger;
+    public static HSEConfiguration Current { get; set; }
+
+    [RequiresUnreferencedCode("Uses reflection binding")]
+    public HSEConfiguration(ILogger<HSEConfiguration> logger, IConfiguration config, HostBuilderContext host) {
+        Current = this;
+        _logger = logger;
+        logger.LogInformation("Loading configuration for environment: {}...", host.HostingEnvironment.EnvironmentName);
+        config.GetSection("HomeserverEmulator").Bind(this);
+        if (StoreData) {
+            DataStoragePath = ExpandPath(DataStoragePath ?? throw new NullReferenceException("DataStoragePath is not set"));
+            CacheStoragePath = ExpandPath(CacheStoragePath ?? throw new NullReferenceException("CacheStoragePath is not set"));
+        }
+
+        _logger.LogInformation("Configuration loaded: {}", this.ToJson());
+    }
+
+    public string CacheStoragePath { get; set; }
+
+    public string DataStoragePath { get; set; }
+
+    public bool StoreData { get; set; } = true;
+    
+    public bool UnknownSyncTokenIsInitialSync { get; set; } = true;
+
+    private static string ExpandPath(string path, bool retry = true) {
+        _logger.LogInformation("Expanding path `{}`", path);
+
+        if (path.StartsWith('~')) {
+            path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[1..]);
+        }
+
+        Environment.GetEnvironmentVariables().Cast<DictionaryEntry>().OrderByDescending(x => x.Key.ToString()!.Length).ToList().ForEach(x => {
+            path = path.Replace($"${x.Key}", x.Value.ToString());
+        });
+
+        _logger.LogInformation("Expanded path to `{}`", path);
+        var tries = 0;
+        while (retry && path.ContainsAnyOf("~$".Split())) {
+            if (tries++ > 100)
+                throw new Exception($"Path `{path}` contains unrecognised environment variables");
+            path = ExpandPath(path, false);
+        }
+        
+        if(path.StartsWith("./"))
+            path = Path.Join(Directory.GetCurrentDirectory(), path[2..].Replace("/", Path.DirectorySeparatorChar.ToString()));
+
+        return path;
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs
new file mode 100644
index 0000000..e34a731
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/MediaStore.cs
@@ -0,0 +1,47 @@
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Collections.ObjectModel;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Collections;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Responses;
+
+namespace LibMatrix.HomeserverEmulator.Services;
+
+public class MediaStore {
+    private readonly HSEConfiguration _config;
+    private List<MediaInfo> index = new();
+
+    public MediaStore(HSEConfiguration config) {
+        _config = config;
+        if (config.StoreData) {
+            var path = Path.Combine(config.DataStoragePath, "media");
+            if (!Directory.Exists(path)) Directory.CreateDirectory(path);
+            if(File.Exists(Path.Combine(path, "index.json")))
+                index = JsonSerializer.Deserialize<List<MediaInfo>>(File.ReadAllText(Path.Combine(path, "index.json")));
+        }
+        else
+            Console.WriteLine("Data storage is disabled, not loading rooms from disk");
+    }
+
+    public async Task<object> UploadMedia(string userId, string mimeType, Stream stream, string? filename = null) {
+        var mediaId = $"mxc://{Guid.NewGuid().ToString()}";
+        var path = Path.Combine(_config.DataStoragePath, "media", mediaId);
+        if (!Directory.Exists(path)) Directory.CreateDirectory(path);
+        var file = Path.Combine(path, filename ?? "file");
+        await using var fs = File.Create(file);
+        await stream.CopyToAsync(fs);
+        index.Add(new() {
+            
+        });
+        return media;
+    }
+
+    public class MediaInfo { }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs
index b4624ab..37d9c7d 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs
@@ -1,5 +1,13 @@
 using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Collections.ObjectModel;
 using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Collections;
+using ArcaneLibs.Extensions;
 using LibMatrix.EventTypes;
 using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.Responses;
@@ -9,6 +17,21 @@ namespace LibMatrix.HomeserverEmulator.Services;
 public class RoomStore {
     public ConcurrentBag<Room> _rooms = new();
     private Dictionary<string, Room> _roomsById = new();
+    
+    public RoomStore(HSEConfiguration config) {
+        if(config.StoreData) {
+            var path = Path.Combine(config.DataStoragePath, "rooms");
+            if (!Directory.Exists(path)) Directory.CreateDirectory(path);
+            foreach (var file in Directory.GetFiles(path)) {
+                var room = JsonSerializer.Deserialize<Room>(File.ReadAllText(file));
+                if (room is not null) _rooms.Add(room);
+            }
+        }
+        else
+            Console.WriteLine("Data storage is disabled, not loading rooms from disk");
+        
+        RebuildIndexes();
+    }
 
     private void RebuildIndexes() {
         _roomsById = _rooms.ToDictionary(u => u.RoomId);
@@ -26,9 +49,7 @@ public class RoomStore {
     }
 
     public Room CreateRoom(CreateRoomRequest request) {
-        var room = new Room {
-            RoomId = $"!{Guid.NewGuid().ToString()}"
-        };
+        var room = new Room(roomId: $"!{Guid.NewGuid().ToString()}");
         if (!string.IsNullOrWhiteSpace(request.Name))
             room.SetStateInternal(new StateEvent() {
                 Type = RoomNameEventContent.EventId,
@@ -54,30 +75,90 @@ public class RoomStore {
         return room;
     }
 
-    public class Room {
+    public class Room : NotifyPropertyChanged {
+        private CancellationTokenSource _debounceCts = new();
+        private ObservableCollection<StateEventResponse> _timeline;
+        private ObservableDictionary<string,List<StateEventResponse>> _accountData;
+
+        public Room(string roomId) {
+            if (string.IsNullOrWhiteSpace(roomId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(roomId));
+            if (roomId[0] != '!') throw new ArgumentException("Room ID must start with !", nameof(roomId));
+            RoomId = roomId;
+            State = FrozenSet<StateEventResponse>.Empty;
+            Timeline = new();
+            AccountData = new();
+        }
+
         public string RoomId { get; set; }
-        public List<StateEventResponse> State { get; set; } = new();
-        public Dictionary<string, EventContent> Timeline { get; set; } = new();
+
+        public FrozenSet<StateEventResponse> State { get; private set; }
+
+        public ObservableCollection<StateEventResponse> Timeline {
+            get => _timeline;
+            set {
+                if (Equals(value, _timeline)) return;
+                _timeline = new(value);
+                _timeline.CollectionChanged += (sender, args) => SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
+
+        public ObservableDictionary<string, List<StateEventResponse>> AccountData { 
+            get => _accountData;
+            set {
+                if (Equals(value, _accountData)) return;
+                _accountData = new(value);
+                _accountData.CollectionChanged += (sender, args) => SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
 
         internal StateEventResponse SetStateInternal(StateEvent request) {
             var state = new StateEventResponse() {
                 Type = request.Type,
                 StateKey = request.StateKey,
-                RawContent = request.RawContent,
-                EventId = Guid.NewGuid().ToString()
+                EventId = Guid.NewGuid().ToString(),
+                RoomId = RoomId,
+                OriginServerTs = DateTimeOffset.Now.ToUnixTimeMilliseconds(),
+                Sender = "",
+                RawContent = request.RawContent ?? (request.TypedContent is not null ? new JsonObject() : JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(request.TypedContent)))  
             };
-            State.Add(state);
+            Timeline.Add(state);
+            if(state.StateKey is not null) 
+            // we want state to be deduplicated by type and key, and we want the latest state to be the one that is returned
+                State = Timeline.Where(s => s.Type == state.Type && s.StateKey == state.StateKey)
+                    .OrderByDescending(s => s.OriginServerTs)
+                    .DistinctBy(x=>(x.Type, x.StateKey))
+                    .ToFrozenSet();
             return state;
         }
 
         public StateEventResponse AddUser(string userId) {
-            return SetStateInternal(new() {
+            var state = SetStateInternal(new() {
                 Type = RoomMemberEventContent.EventId,
                 StateKey = userId,
                 TypedContent = new RoomMemberEventContent() {
                     Membership = "join"
-                }
+                },
             });
+
+            state.Sender = userId;
+            return state;
+        }
+        
+        public async Task SaveDebounced() {
+            if (!HSEConfiguration.Current.StoreData) return;
+            await _debounceCts.CancelAsync();
+            _debounceCts = new CancellationTokenSource();
+            try {
+                await Task.Delay(250, _debounceCts.Token);
+                // Ensure all state events are in the timeline
+                State.Where(s=>!Timeline.Contains(s)).ToList().ForEach(s => Timeline.Add(s));
+                var path = Path.Combine(HSEConfiguration.Current.DataStoragePath, "rooms", $"{RoomId}.json");
+                Console.WriteLine($"Saving room {RoomId} to {path}!");
+                await File.WriteAllTextAsync(path, this.ToJson(ignoreNull: true));
+            }
+            catch (TaskCanceledException) { }
         }
     }
 }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs b/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs
index 8115bee..1f59342 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs
@@ -1,9 +1,7 @@
 namespace LibMatrix.HomeserverEmulator.Services;
 
-public class TokenService(IHttpContextAccessor accessor) {
-    public string? GetAccessToken() {
-        var ctx = accessor.HttpContext;
-        if (ctx is null) return null;
+public class TokenService{
+    public string? GetAccessToken(HttpContext ctx) {
         //qry
         if (ctx.Request.Query.TryGetValue("access_token", out var token)) {
             return token;
@@ -11,16 +9,14 @@ public class TokenService(IHttpContextAccessor accessor) {
         //header
         if (ctx.Request.Headers.TryGetValue("Authorization", out var auth)) {
             var parts = auth.ToString().Split(' ');
-            if (parts.Length == 2 && parts[0] == "Bearer") {
+            if (parts is ["Bearer", _]) {
                 return parts[1];
             }
         }
         return null;
     }
 
-    public string? GenerateServerName() {
-        var ctx = accessor.HttpContext;
-        if (ctx is null) return null;
+    public string? GenerateServerName(HttpContext ctx) {
         return ctx.Request.Host.ToString();
     }
 }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs
index ca1c577..faf0046 100644
--- a/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs
@@ -1,72 +1,207 @@
+using System.Collections.Concurrent;
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ArcaneLibs;
+using ArcaneLibs.Collections;
+using ArcaneLibs.Extensions;
 using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Filters;
+using LibMatrix.Responses;
 
 namespace LibMatrix.HomeserverEmulator.Services;
 
-public class UserStore(RoomStore roomStore) {
-    public List<User> _users = new();
-    private Dictionary<string, User> _usersById = new();
-    private Dictionary<string, User> _usersByToken = new();
+public class UserStore {
+    public ConcurrentBag<User> _users = new();
+    private readonly RoomStore _roomStore;
 
-    private void RebuildIndexes() {
-        _usersById = _users.ToDictionary(u => u.UserId);
-        _usersByToken = _users.ToDictionary(u => u.AccessToken);
+    public UserStore(HSEConfiguration config, RoomStore roomStore) {
+        _roomStore = roomStore;
+        if (config.StoreData) {
+            var path = Path.Combine(config.DataStoragePath, "users");
+            if (!Directory.Exists(path)) Directory.CreateDirectory(path);
+            foreach (var file in Directory.GetFiles(path)) {
+                var user = JsonSerializer.Deserialize<User>(File.ReadAllText(file));
+                if (user is not null) _users.Add(user);
+            }
+
+            Console.WriteLine($"Loaded {_users.Count} users from disk");
+        }
+        else {
+            Console.WriteLine("Data storage is disabled, not loading users from disk");
+        }
     }
 
     public async Task<User?> GetUserById(string userId, bool createIfNotExists = false) {
-        if (_usersById.TryGetValue(userId, out var user)) {
-            return user;
-        }
+        if (_users.Any(x => x.UserId == userId))
+            return _users.First(x => x.UserId == userId);
 
         if (!createIfNotExists)
             return null;
 
-        return await CreateUser(userId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new Dictionary<string, object>());
+        return await CreateUser(userId);
     }
 
     public async Task<User?> GetUserByToken(string token, bool createIfNotExists = false, string? serverName = null) {
-        if (_usersByToken.TryGetValue(token, out var user)) {
-            return user;
-        }
+        if (_users.Any(x => x.AccessTokens.ContainsKey(token)))
+            return _users.First(x => x.AccessTokens.ContainsKey(token));
 
         if (!createIfNotExists)
             return null;
         if (string.IsNullOrWhiteSpace(serverName)) throw new NullReferenceException("Server name was not passed");
         var uid = $"@{Guid.NewGuid().ToString()}:{serverName}";
-        return await CreateUser(uid, Guid.NewGuid().ToString(), token, new Dictionary<string, object>());
+        return await CreateUser(uid);
     }
 
-    public async Task<User> CreateUser(string userId, string deviceId, string accessToken, Dictionary<string, object> profile) {
+    public async Task<User> CreateUser(string userId, Dictionary<string, object>? profile = null) {
+        profile ??= new();
         if (!profile.ContainsKey("displayname")) profile.Add("displayname", userId.Split(":")[0]);
         if (!profile.ContainsKey("avatar_url")) profile.Add("avatar_url", null);
-        var user = new User {
+        var user = new User() {
             UserId = userId,
-            DeviceId = deviceId,
-            AccessToken = accessToken,
-            Profile = profile
+            AccountData = new() {
+                new StateEventResponse() {
+                    Type = "im.vector.analytics",
+                    RawContent = new JsonObject() {
+                        ["pseudonymousAnalyticsOptIn"] = false
+                    },
+                },
+                new StateEventResponse() {
+                    Type = "im.vector.web.settings",
+                    RawContent = new JsonObject() {
+                        ["developerMode"] = true
+                    }
+                },
+            }
         };
+        user.Profile.AddRange(profile);
         _users.Add(user);
-        RebuildIndexes();
-
-        if (roomStore._rooms.Count > 0)
-            foreach (var item in Random.Shared.GetItems(roomStore._rooms.ToArray(), Math.Min(roomStore._rooms.Count, 400))) {
+        if (!_roomStore._rooms.IsEmpty)
+            foreach (var item in Random.Shared.GetItems(_roomStore._rooms.ToArray(), Math.Min(_roomStore._rooms.Count, 400))) {
                 item.AddUser(userId);
             }
 
         int random = Random.Shared.Next(10);
         for (int i = 0; i < random; i++) {
-            var room = roomStore.CreateRoom(new());
+            var room = _roomStore.CreateRoom(new());
             room.AddUser(userId);
         }
 
         return user;
     }
 
-    public class User {
-        public string UserId { get; set; }
-        public string AccessToken { get; set; }
-        public string DeviceId { get; set; }
-        public Dictionary<string, object> Profile { get; set; }
+    public class User : NotifyPropertyChanged {
+        public User() {
+            AccessTokens = new();
+            Filters = new();
+            Profile = new();
+            AccountData = new();
+            RoomKeys = new();
+        }
+
+        private CancellationTokenSource _debounceCts = new();
+        private string _userId;
+        private ObservableDictionary<string, SessionInfo> _accessTokens;
+        private ObservableDictionary<string, SyncFilter> _filters;
+        private ObservableDictionary<string, object> _profile;
+        private ObservableCollection<StateEventResponse> _accountData;
+        private ObservableDictionary<string, RoomKeysResponse> _roomKeys;
+
+        public string UserId {
+            get => _userId;
+            set => SetField(ref _userId, value);
+        }
+
+        public ObservableDictionary<string, SessionInfo> AccessTokens {
+            get => _accessTokens;
+            set {
+                if (value == _accessTokens) return;
+                _accessTokens = new(value);
+                _accessTokens.CollectionChanged += async (sender, args) => await SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
+
+        public ObservableDictionary<string, SyncFilter> Filters {
+            get => _filters;
+            set {
+                if (value == _filters) return;
+                _filters = new(value);
+                _filters.CollectionChanged += async (sender, args) => await SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
+
+        public ObservableDictionary<string, object> Profile {
+            get => _profile;
+            set {
+                if (value == _profile) return;
+                _profile = new(value);
+                _profile.CollectionChanged += async (sender, args) => await SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
+
+        public ObservableCollection<StateEventResponse> AccountData {
+            get => _accountData;
+            set {
+                if (value == _accountData) return;
+                _accountData = new(value);
+                _accountData.CollectionChanged += async (sender, args) => await SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
+
+        public ObservableDictionary<string, RoomKeysResponse> RoomKeys {
+            get => _roomKeys;
+            set {
+                if (value == _roomKeys) return;
+                _roomKeys = new(value);
+                _roomKeys.CollectionChanged += async (sender, args) => await SaveDebounced();
+                OnPropertyChanged();
+            }
+        }
+
+        public async Task SaveDebounced() {
+            if (!HSEConfiguration.Current.StoreData) return;
+            _debounceCts.Cancel();
+            _debounceCts = new CancellationTokenSource();
+            try {
+                await Task.Delay(250, _debounceCts.Token);
+                var path = Path.Combine(HSEConfiguration.Current.DataStoragePath, "users", $"{_userId}.json");
+                Console.WriteLine($"Saving user {_userId} to {path}!");
+                await File.WriteAllTextAsync(path, this.ToJson(ignoreNull: true));
+            }
+            catch (TaskCanceledException) { }
+            catch (InvalidOperationException) { } // We don't care about 100% data safety, this usually happens when something is updated while serialising
+        }
+
+        public class SessionInfo {
+            public string DeviceId { get; set; } = Guid.NewGuid().ToString();
+            public Dictionary<string, UserSyncState> SyncStates { get; set; } = new();
+
+            public class UserSyncState {
+                public Dictionary<string, SyncRoomPosition> RoomPositions { get; set; } = new();
+                public string FilterId { get; set; }
+                public DateTime SyncStateCreated { get; set; } = DateTime.Now;
 
-        public List<string> JoinedRooms { get; set; } = new();
+                public class SyncRoomPosition {
+                    public int TimelinePosition { get; set; }
+                    public int StatePosition { get; set; }
+                    public int AccountDataPosition { get; set; }
+                }
+            }
+        }
+
+        public LoginResponse Login() {
+            var session = new SessionInfo();
+            AccessTokens.Add(Guid.NewGuid().ToString(), session);
+            SaveDebounced();
+            return new LoginResponse() {
+                AccessToken = AccessTokens.Keys.Last(),
+                DeviceId = session.DeviceId,
+                UserId = UserId
+            };
+        }
     }
 }
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json b/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json
index df83ec5..f14522d 100644
--- a/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json
+++ b/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json
@@ -4,7 +4,9 @@
       "Default": "Information",
       "Microsoft.AspNetCore": "Information",
       "Microsoft.AspNetCore.Routing": "Warning",
-        "Microsoft.AspNetCore.Mvc": "Warning"
+      "Microsoft.AspNetCore.Mvc": "Warning"
     }
+  },
+  "HomeserverEmulator": {
   }
 }