about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-02-23 11:23:27 +0000
committerRory& <root@rory.gay>2024-02-23 11:23:27 +0000
commit3dfb7b81b0fe19d37a7bf1183e248ca10c56277c (patch)
tree52c36b45c41e32cddd90dfcd110a393158f55675
parentApply syntax style to LibMatrix side projects (diff)
downloadLibMatrix-3dfb7b81b0fe19d37a7bf1183e248ca10c56277c.tar.xz
HS emulator
-rw-r--r--LibMatrix.sln7
-rw-r--r--LibMatrix/Homeservers/RemoteHomeServer.cs2
-rw-r--r--LibMatrix/MatrixException.cs2
-rw-r--r--LibMatrix/Services/HomeserverResolverService.cs18
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs23
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs18
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/UserController.cs81
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/VersionsController.cs48
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Controllers/WellKnownController.cs32
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj21
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.http6
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Program.cs90
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json31
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs83
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs26
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs72
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json10
-rw-r--r--Tests/LibMatrix.HomeserverEmulator/appsettings.json41
18 files changed, 607 insertions, 4 deletions
diff --git a/LibMatrix.sln b/LibMatrix.sln
index f3eae7d..c068216 100644
--- a/LibMatrix.sln
+++ b/LibMatrix.sln
@@ -35,6 +35,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs", "ArcaneLibs\Ar
 EndProject

 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.EventTypes", "LibMatrix.EventTypes\LibMatrix.EventTypes.csproj", "{CD13665B-B964-4AB0-991B-12F067B16DA3}"

 EndProject

+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.HomeserverEmulator", "Tests\LibMatrix.HomeserverEmulator\LibMatrix.HomeserverEmulator.csproj", "{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}"

+EndProject

 Global

 	GlobalSection(SolutionConfigurationPlatforms) = preSolution

 		Debug|Any CPU = Debug|Any CPU

@@ -92,6 +94,10 @@ Global
 		{CD13665B-B964-4AB0-991B-12F067B16DA3}.Debug|Any CPU.Build.0 = Debug|Any CPU

 		{CD13665B-B964-4AB0-991B-12F067B16DA3}.Release|Any CPU.ActiveCfg = Release|Any CPU

 		{CD13665B-B964-4AB0-991B-12F067B16DA3}.Release|Any CPU.Build.0 = Release|Any CPU

+		{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU

+		{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Debug|Any CPU.Build.0 = Debug|Any CPU

+		{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Release|Any CPU.ActiveCfg = Release|Any CPU

+		{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Release|Any CPU.Build.0 = Release|Any CPU

 	EndGlobalSection

 	GlobalSection(NestedProjects) = preSolution

 		{1B1B2197-61FB-416F-B6C8-845F2E5A0442} = {840309F0-435B-43A7-8471-8C2BE643889D}

@@ -103,5 +109,6 @@ Global
 		{0B9B34D1-9362-45A9-9C21-816FD6959110} = {BFE16D8E-EFC5-49F6-9854-DB001309B3B4}

 		{4D9B5227-48DC-4A30-9263-AFB51DC01ABB} = {A6345ECE-4C5E-400F-9130-886E343BF314}

 		{13A797D1-7E13-4789-A167-8628B1641AC0} = {01A126FE-9D50-40F2-817B-E55F4065EA76}

+		{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2} = {BFE16D8E-EFC5-49F6-9854-DB001309B3B4}

 	EndGlobalSection

 EndGlobal

diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs
index 5c7d254..422a8a9 100644
--- a/LibMatrix/Homeservers/RemoteHomeServer.cs
+++ b/LibMatrix/Homeservers/RemoteHomeServer.cs
@@ -35,7 +35,7 @@ public class RemoteHomeserver(string baseUrl) {
 
         homeserver.ClientHttpClient = new MatrixHttpClient {
             BaseAddress = new Uri(proxy ?? homeserver.WellKnownUris.Client ?? throw new InvalidOperationException($"Failed to resolve homeserver client URI for {baseUrl}")),
-            Timeout = TimeSpan.FromSeconds(120)
+            Timeout = TimeSpan.FromSeconds(300)
         };
 
         homeserver.FederationClient = await FederationClient.TryCreate(baseUrl, proxy);
diff --git a/LibMatrix/MatrixException.cs b/LibMatrix/MatrixException.cs
index 86dbce4..8ec8fd5 100644
--- a/LibMatrix/MatrixException.cs
+++ b/LibMatrix/MatrixException.cs
@@ -20,7 +20,7 @@ public class MatrixException : Exception {
 
     public string RawContent { get; set; }
 
-    public object GetAsObject() => new { ErrorCode, Error, SoftLogout, RetryAfterMs };
+    public object GetAsObject() => new { errcode = ErrorCode, error = Error, soft_logout = SoftLogout, retry_after_ms = RetryAfterMs };
     public string GetAsJson() => GetAsObject().ToJson(ignoreNull: true);
 
     public override string Message =>
diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs
index a4a18e5..bcef541 100644
--- a/LibMatrix/Services/HomeserverResolverService.cs
+++ b/LibMatrix/Services/HomeserverResolverService.cs
@@ -34,7 +34,14 @@ public class HomeserverResolverService(ILogger<HomeserverResolverService>? logge
     }
 
     private async Task<string?> _tryResolveFromClientWellknown(string homeserver) {
-        if (!homeserver.StartsWith("http")) homeserver = "https://" + homeserver;
+        if (!homeserver.StartsWith("http")) {
+            if (await _httpClient.CheckSuccessStatus($"https://{homeserver}/.well-known/matrix/client"))
+                homeserver = "https://" + homeserver;
+            else if (await _httpClient.CheckSuccessStatus($"http://{homeserver}/.well-known/matrix/client")) {
+                homeserver = "http://" + homeserver;
+            }
+        }
+
         try {
             var resp = await _httpClient.GetFromJsonAsync<JsonElement>($"{homeserver}/.well-known/matrix/client");
             var hs = resp.GetProperty("m.homeserver").GetProperty("base_url").GetString();
@@ -49,7 +56,14 @@ public class HomeserverResolverService(ILogger<HomeserverResolverService>? logge
     }
 
     private async Task<string?> _tryResolveFromServerWellknown(string homeserver) {
-        if (!homeserver.StartsWith("http")) homeserver = "https://" + homeserver;
+        if (!homeserver.StartsWith("http")) {
+            if (await _httpClient.CheckSuccessStatus($"https://{homeserver}/.well-known/matrix/server"))
+                homeserver = "https://" + homeserver;
+            else if (await _httpClient.CheckSuccessStatus($"http://{homeserver}/.well-known/matrix/server")) {
+                homeserver = "http://" + homeserver;
+            }
+        }
+
         try {
             var resp = await _httpClient.GetFromJsonAsync<JsonElement>($"{homeserver}/.well-known/matrix/server");
             var hs = resp.GetProperty("m.server").GetString();
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs
new file mode 100644
index 0000000..d0496bf
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/AuthController.cs
@@ -0,0 +1,23 @@
+using System.Text.Json.Nodes;

+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 AuthController(ILogger<AuthController> logger, UserStore userStore) : 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

+        };

+

+        return loginResponse;

+    }

+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs b/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs
new file mode 100644
index 0000000..0c4d8bd
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Controllers/HEDebug/HEDebugController.cs
@@ -0,0 +1,18 @@
+using LibMatrix.HomeserverEmulator.Services;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

+[Route("/_hsEmulator")]

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

+    [HttpGet("users")]

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

+        return userStore._users;

+    }

+    

+    [HttpGet("rooms")]

+    public async Task<List<RoomStore.Room>> GetRooms() {

+        return roomStore._rooms.ToList();

+    }

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

+using ArcaneLibs.Extensions;

+using LibMatrix.HomeserverEmulator.Services;

+using LibMatrix.Responses;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

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

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

+    [HttpGet("account/whoami")]

+    public async Task<WhoAmIResponse> Login() {

+        var token = tokenService.GetAccessToken();

+        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());

+        if (user is null)

+            throw new MatrixException() {

+                ErrorCode = "M_UNKNOWN_TOKEN",

+                Error = "Invalid token."

+            };

+        var whoAmIResponse = new WhoAmIResponse {

+            UserId = user.UserId

+        };

+        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();

+        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 user.JoinedRooms;

+

+        return new {

+            joined_rooms = user.JoinedRooms

+        };

+    }

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

+using LibMatrix.Homeservers;

+using LibMatrix.Responses;

+using LibMatrix.Services;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

+[Route("/_matrix/")]

+public class VersionsController(ILogger<WellKnownController> logger) : ControllerBase {

+    [HttpGet("client/versions")]

+    public async Task<ClientVersionsResponse> GetClientVersions() {

+        var clientVersions = new ClientVersionsResponse {

+            Versions = new() {

+                "r0.0.1",

+                "r0.1.0",

+                "r0.2.0",

+                "r0.3.0",

+                "r0.4.0",

+                "r0.5.0",

+                "r0.6.0",

+                "r0.6.1",

+                "v1.1",

+                "v1.2",

+                "v1.3",

+                "v1.4",

+                "v1.5",

+                "v1.6",

+                "v1.7",

+                "v1.8",

+            },

+            UnstableFeatures = new()

+        };

+        return clientVersions;

+    }

+

+    [HttpGet("federation/v1/version")]

+    public async Task<ServerVersionResponse> GetServerVersions() {

+        var clientVersions = new ServerVersionResponse() {

+            Server = new() {

+                Name = "LibMatrix.HomeserverEmulator",

+                Version = "0.0.0"

+            }

+        };

+        return clientVersions;

+    }

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

+using LibMatrix.Services;

+using Microsoft.AspNetCore.Mvc;

+

+namespace LibMatrix.HomeserverEmulator.Controllers;

+

+[ApiController]

+[Route("/.well-known/matrix/")]

+public class WellKnownController(ILogger<WellKnownController> logger) : ControllerBase {

+    [HttpGet("client")]

+    public JsonObject GetClientWellKnown() {

+        var obj = new JsonObject() {

+            ["m.homeserver"] = new JsonObject() {

+                ["base_url"] = $"{Request.Scheme}://{Request.Host}"

+            }

+        };

+

+        logger.LogInformation("Serving client well-known: {}", obj);

+

+        return obj;

+    }

+    [HttpGet("server")]

+    public JsonObject GetServerWellKnown() {

+        var obj = new JsonObject() {

+            ["m.server"] = $"{Request.Scheme}://{Request.Host}"

+        };

+

+        logger.LogInformation("Serving server well-known: {}", obj);

+

+        return obj;

+    }

+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj
new file mode 100644
index 0000000..e6b4572
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.csproj
@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">

+

+  <PropertyGroup>

+    <TargetFramework>net8.0</TargetFramework>

+    <Nullable>enable</Nullable>

+    <ImplicitUsings>enable</ImplicitUsings>

+    <InvariantGlobalization>true</InvariantGlobalization>

+    <LangVersion>preview</LangVersion>

+    <GenerateDocumentationFile>true</GenerateDocumentationFile>

+  </PropertyGroup>

+

+  <ItemGroup>

+    <PackageReference Include="EasyCompressor.LZMA" Version="1.4.0" />

+    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />

+    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />

+  </ItemGroup>

+

+  <ItemGroup>

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

+  </ItemGroup>

+</Project>

diff --git a/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.http b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.http
new file mode 100644
index 0000000..01d5fcc
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/LibMatrix.HomeserverEmulator.http
@@ -0,0 +1,6 @@
+@LibMatrix.HomeserverEmulator_HostAddress = http://localhost:5298

+

+GET {{LibMatrix.HomeserverEmulator_HostAddress}}/weatherforecast/

+Accept: application/json

+

+###

diff --git a/Tests/LibMatrix.HomeserverEmulator/Program.cs b/Tests/LibMatrix.HomeserverEmulator/Program.cs
new file mode 100644
index 0000000..516d380
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Program.cs
@@ -0,0 +1,90 @@
+using System.Net.Mime;

+using LibMatrix;

+using LibMatrix.HomeserverEmulator.Services;

+using Microsoft.AspNetCore.Diagnostics;

+using Microsoft.AspNetCore.Http.Timeouts;

+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.AddEndpointsApiExplorer();

+builder.Services.AddSwaggerGen(c => {

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

+        Version = "v1",

+        Title = "Rory&::LibMatrix.HomeserverEmulator",

+        Description = "Partial Matrix implementation"

+    });

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

+});

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

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

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

+

+

+builder.Services.AddScoped<TokenService>();

+

+builder.Services.AddRequestTimeouts(x => {

+    x.DefaultPolicy = new RequestTimeoutPolicy {

+        Timeout = TimeSpan.FromMinutes(10),

+        WriteTimeoutResponse = async context => {

+            context.Response.StatusCode = 504;

+            context.Response.ContentType = "application/json";

+            await context.Response.StartAsync();

+            await context.Response.WriteAsJsonAsync(new MatrixException() {

+                ErrorCode = "M_TIMEOUT",

+                Error = "Request timed out"

+            }.GetAsJson());

+            await context.Response.CompleteAsync();

+        }

+    };

+});

+builder.Services.AddCors(options => {

+    options.AddPolicy(

+        "Open",

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

+});

+var app = builder.Build();

+

+// Configure the HTTP request pipeline.

+if (app.Environment.IsDevelopment() || true)

+{

+    app.UseSwagger();

+    app.UseSwaggerUI();

+}

+

+app.UseCors("Open");

+app.UseExceptionHandler(exceptionHandlerApp => {

+    exceptionHandlerApp.Run(async context => {

+

+        var exceptionHandlerPathFeature =

+            context.Features.Get<IExceptionHandlerPathFeature>();

+

+        if (exceptionHandlerPathFeature?.Error is MatrixException mxe) {

+            context.Response.StatusCode = mxe.ErrorCode switch {

+                "M_NOT_FOUND" => StatusCodes.Status404NotFound,

+                "M_UNAUTHORIZED" => StatusCodes.Status401Unauthorized,

+                _ => StatusCodes.Status500InternalServerError

+            };

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

+            await context.Response.WriteAsync(mxe.GetAsJson()!);

+        }

+        else {

+            context.Response.StatusCode = StatusCodes.Status500InternalServerError;

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

+            await context.Response.WriteAsync(new MatrixException() {

+                ErrorCode = "M_UNKNOWN",

+                Error = exceptionHandlerPathFeature?.Error.ToString()

+            }.GetAsJson());

+        }

+    });

+});

+

+app.UseAuthorization();

+

+app.MapControllers();

+

+app.Run();

diff --git a/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json b/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json
new file mode 100644
index 0000000..8ab6b3d
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{

+  "$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"

+      }

+    }

+  }

+}

diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs b/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs
new file mode 100644
index 0000000..b4624ab
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/RoomStore.cs
@@ -0,0 +1,83 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using LibMatrix.EventTypes;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Responses;
+
+namespace LibMatrix.HomeserverEmulator.Services;
+
+public class RoomStore {
+    public ConcurrentBag<Room> _rooms = new();
+    private Dictionary<string, Room> _roomsById = new();
+
+    private void RebuildIndexes() {
+        _roomsById = _rooms.ToDictionary(u => u.RoomId);
+    }
+
+    public Room? GetRoomById(string roomId, bool createIfNotExists = false) {
+        if (_roomsById.TryGetValue(roomId, out var user)) {
+            return user;
+        }
+
+        if (!createIfNotExists)
+            return null;
+
+        return CreateRoom(new() { });
+    }
+
+    public Room CreateRoom(CreateRoomRequest request) {
+        var room = new Room {
+            RoomId = $"!{Guid.NewGuid().ToString()}"
+        };
+        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"
+                }
+            });
+
+        foreach (var stateEvent in request.InitialState ?? []) {
+            room.SetStateInternal(stateEvent);
+        }
+
+        _rooms.Add(room);
+        RebuildIndexes();
+        return room;
+    }
+
+    public class Room {
+        public string RoomId { get; set; }
+        public List<StateEventResponse> State { get; set; } = new();
+        public Dictionary<string, EventContent> Timeline { get; set; } = new();
+
+        internal StateEventResponse SetStateInternal(StateEvent request) {
+            var state = new StateEventResponse() {
+                Type = request.Type,
+                StateKey = request.StateKey,
+                RawContent = request.RawContent,
+                EventId = Guid.NewGuid().ToString()
+            };
+            State.Add(state);
+            return state;
+        }
+
+        public StateEventResponse AddUser(string userId) {
+            return SetStateInternal(new() {
+                Type = RoomMemberEventContent.EventId,
+                StateKey = userId,
+                TypedContent = new RoomMemberEventContent() {
+                    Membership = "join"
+                }
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs b/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs
new file mode 100644
index 0000000..8115bee
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/TokenService.cs
@@ -0,0 +1,26 @@
+namespace LibMatrix.HomeserverEmulator.Services;
+
+public class TokenService(IHttpContextAccessor accessor) {
+    public string? GetAccessToken() {
+        var ctx = accessor.HttpContext;
+        if (ctx is null) return null;
+        //qry
+        if (ctx.Request.Query.TryGetValue("access_token", out var token)) {
+            return token;
+        }
+        //header
+        if (ctx.Request.Headers.TryGetValue("Authorization", out var auth)) {
+            var parts = auth.ToString().Split(' ');
+            if (parts.Length == 2 && parts[0] == "Bearer") {
+                return parts[1];
+            }
+        }
+        return null;
+    }
+
+    public string? GenerateServerName() {
+        var ctx = accessor.HttpContext;
+        if (ctx is null) return null;
+        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
new file mode 100644
index 0000000..ca1c577
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/Services/UserStore.cs
@@ -0,0 +1,72 @@
+using LibMatrix.EventTypes.Spec.State;
+
+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();
+
+    private void RebuildIndexes() {
+        _usersById = _users.ToDictionary(u => u.UserId);
+        _usersByToken = _users.ToDictionary(u => u.AccessToken);
+    }
+
+    public async Task<User?> GetUserById(string userId, bool createIfNotExists = false) {
+        if (_usersById.TryGetValue(userId, out var user)) {
+            return user;
+        }
+
+        if (!createIfNotExists)
+            return null;
+
+        return await CreateUser(userId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new Dictionary<string, object>());
+    }
+
+    public async Task<User?> GetUserByToken(string token, bool createIfNotExists = false, string? serverName = null) {
+        if (_usersByToken.TryGetValue(token, out var user)) {
+            return user;
+        }
+
+        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>());
+    }
+
+    public async Task<User> CreateUser(string userId, string deviceId, string accessToken, Dictionary<string, object> profile) {
+        if (!profile.ContainsKey("displayname")) profile.Add("displayname", userId.Split(":")[0]);
+        if (!profile.ContainsKey("avatar_url")) profile.Add("avatar_url", null);
+        var user = new User {
+            UserId = userId,
+            DeviceId = deviceId,
+            AccessToken = accessToken,
+            Profile = 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))) {
+                item.AddUser(userId);
+            }
+
+        int random = Random.Shared.Next(10);
+        for (int i = 0; i < random; i++) {
+            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 List<string> JoinedRooms { get; set; } = new();
+    }
+}
\ No newline at end of file
diff --git a/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json b/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json
new file mode 100644
index 0000000..df83ec5
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/appsettings.Development.json
@@ -0,0 +1,10 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Information",
+      "Microsoft.AspNetCore.Routing": "Warning",
+        "Microsoft.AspNetCore.Mvc": "Warning"
+    }
+  }
+}
diff --git a/Tests/LibMatrix.HomeserverEmulator/appsettings.json b/Tests/LibMatrix.HomeserverEmulator/appsettings.json
new file mode 100644
index 0000000..b16968a
--- /dev/null
+++ b/Tests/LibMatrix.HomeserverEmulator/appsettings.json
@@ -0,0 +1,41 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*",
+  // Configuration for the proxy
+  "MxApiExtensions": {
+    // WARNING: this exposes user tokens to servers listed here, which could be a security risk
+    // Only list servers you trust!
+    // Keep in mind that token conflicts can occur between servers!
+    "AuthHomeservers": [
+      "rory.gay",
+      "conduit.rory.gay"
+    ],
+    // List of administrator MXIDs for the proxy, this allows them to use administrative and debug endpoints
+    "Admins": [
+      "@emma:rory.gay",
+      "@emma:conduit.rory.gay"
+    ],
+    "FastInitialSync": {
+      "Enabled": true,
+      "UseRoomInfoCache": true
+    },
+    "Cache": {
+      "RoomInfo": {
+        "BaseTtl": "00:01:00",
+        "ExtraTtlPerState": "00:00:00.1000000"
+      }
+    },
+    "DefaultUserConfiguration": {
+      "ProtocolChanges": {
+        "DisableThreads": false,
+        "DisableVoip": false,
+        "AutoFollowTombstones": false
+      }
+    }
+  }
+}