Index: binary-improvements2/WebServer/ModInfo.xml
===================================================================
--- binary-improvements2/WebServer/ModInfo.xml	(revision 433)
+++ binary-improvements2/WebServer/ModInfo.xml	(revision 434)
@@ -5,5 +5,5 @@
 	<Description value="Integrated Webserver for the Web Dashboard and server APIs" />
 	<Author value="The Fun Pimps LLC" />
-	<Version value="21.0.280.0" />
+	<Version value="21.0.288.0" />
 	<Website value="" />
 </xml>
Index: binary-improvements2/WebServer/WebServer.csproj
===================================================================
--- binary-improvements2/WebServer/WebServer.csproj	(revision 433)
+++ binary-improvements2/WebServer/WebServer.csproj	(revision 434)
@@ -124,5 +124,13 @@
     <Compile Include="src\WebAPI\APIs\GameData\Mods.cs" />
     <Compile Include="src\WebAPI\APIs\Log.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\Blacklist.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\CommandPermissions.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\PermissionsApiHelpers.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\UserPermissions.cs" />
     <Compile Include="src\WebAPI\APIs\Permissions\RegisterUser.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\WebApiTokens.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\WebModules.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\WebUsers.cs" />
+    <Compile Include="src\WebAPI\APIs\Permissions\Whitelist.cs" />
     <Compile Include="src\WebAPI\APIs\ServerInfo.cs" />
     <Compile Include="src\WebAPI\APIs\ServerStats.cs" />
Index: binary-improvements2/WebServer/src/Commands/WebPermissionsCmd.cs
===================================================================
--- binary-improvements2/WebServer/src/Commands/WebPermissionsCmd.cs	(revision 433)
+++ binary-improvements2/WebServer/src/Commands/WebPermissionsCmd.cs	(revision 434)
@@ -139,18 +139,20 @@
 
 				SdtdConsole.Instance.Output ($"  {wmp.Name,-25}: {wmp.LevelGlobal,4}{(wmp.IsDefault ? " (default permissions)" : "")}");
-				if (wmp.LevelPerMethod != null) {
-					for (int iMethod = 0; iMethod < wmp.LevelPerMethod.Length; iMethod++) {
-						int methodLevel = wmp.LevelPerMethod [iMethod];
-						ERequestMethod method = (ERequestMethod)iMethod;
+				if (wmp.LevelPerMethod == null) {
+					continue;
+				}
+
+				for (int iMethod = 0; iMethod < wmp.LevelPerMethod.Length; iMethod++) {
+					int methodLevel = wmp.LevelPerMethod [iMethod];
+					ERequestMethod method = (ERequestMethod)iMethod;
 						
-						if (methodLevel == AdminWebModules.MethodLevelNotSupported) {
-							continue;
-						}
+					if (methodLevel == AdminWebModules.MethodLevelNotSupported) {
+						continue;
+					}
 						
-						if (methodLevel == AdminWebModules.MethodLevelInheritGlobal) {
-							SdtdConsole.Instance.Output ($"  {method.ToStringCached (),25}: {wmp.LevelGlobal,4} (Using API level)");
-						} else {
-							SdtdConsole.Instance.Output ($"  {method.ToStringCached (),25}: {methodLevel,4}");
-						}
+					if (methodLevel == AdminWebModules.MethodLevelInheritGlobal) {
+						SdtdConsole.Instance.Output ($"  {method.ToStringCached (),25}: {wmp.LevelGlobal,4} (Using API level)");
+					} else {
+						SdtdConsole.Instance.Output ($"  {method.ToStringCached (),25}: {methodLevel,4}");
 					}
 				}
Index: binary-improvements2/WebServer/src/LogBuffer.cs
===================================================================
--- binary-improvements2/WebServer/src/LogBuffer.cs	(revision 433)
+++ binary-improvements2/WebServer/src/LogBuffer.cs	(revision 434)
@@ -64,8 +64,10 @@
 			lock (logEntries) {
 				logEntries.Add (le);
-				if (logEntries.Count > maxEntries) {
-					listOffset += logEntries.Count - maxEntries;
-					logEntries.RemoveRange (0, logEntries.Count - maxEntries);
+				if (logEntries.Count <= maxEntries) {
+					return;
 				}
+
+				listOffset += logEntries.Count - maxEntries;
+				logEntries.RemoveRange (0, logEntries.Count - maxEntries);
 			}
 		}
@@ -124,19 +126,19 @@
 
 		public class LogEntry {
-			public readonly DateTime timestamp;
-			public readonly string isoTime;
-			public readonly string message;
-			public readonly string trace;
-			public readonly LogType type;
-			public readonly long uptime;
+			public readonly DateTime Timestamp;
+			public readonly string IsoTime;
+			public readonly string Message;
+			public readonly string Trace;
+			public readonly LogType Type;
+			public readonly long Uptime;
 
 			public LogEntry (DateTime _timestamp, string _message, string _trace, LogType _type, long _uptime) {
-				timestamp = _timestamp;
-				isoTime = _timestamp.ToString ("o");
+				Timestamp = _timestamp;
+				IsoTime = _timestamp.ToString ("o");
 
-				message = _message;
-				trace = _trace;
-				type = _type;
-				uptime = _uptime;
+				Message = _message;
+				Trace = _trace;
+				Type = _type;
+				Uptime = _uptime;
 			}
 		}
Index: binary-improvements2/WebServer/src/Permissions/AdminWebModules.cs
===================================================================
--- binary-improvements2/WebServer/src/Permissions/AdminWebModules.cs	(revision 433)
+++ binary-improvements2/WebServer/src/Permissions/AdminWebModules.cs	(revision 434)
@@ -1,5 +1,4 @@
 using System.Collections.Generic;
 using System.Xml;
-using UnityEngine;
 
 namespace Webserver.Permissions {
Index: binary-improvements2/WebServer/src/UrlHandlers/AbsHandler.cs
===================================================================
--- binary-improvements2/WebServer/src/UrlHandlers/AbsHandler.cs	(revision 433)
+++ binary-improvements2/WebServer/src/UrlHandlers/AbsHandler.cs	(revision 434)
@@ -3,13 +3,12 @@
 namespace Webserver.UrlHandlers {
 	public abstract class AbsHandler {
-		protected readonly string moduleName;
+		public readonly string ModuleName;
 		protected string urlBasePath;
 		protected Web parent;
 
-		public string ModuleName => moduleName;
 		public string UrlBasePath => urlBasePath;
 
 		protected AbsHandler (string _moduleName, int _defaultPermissionLevel = 0) {
-			moduleName = _moduleName;
+			ModuleName = _moduleName;
 			AdminWebModules.Instance.AddKnownModule (new AdminWebModules.WebModule(_moduleName, _defaultPermissionLevel, true));
 		}
@@ -18,5 +17,5 @@
 
 		public virtual bool IsAuthorizedForHandler (WebConnection _user, int _permissionLevel) {
-			return moduleName == null || AdminWebModules.Instance.ModuleAllowedWithLevel (moduleName, _permissionLevel);
+			return ModuleName == null || AdminWebModules.Instance.ModuleAllowedWithLevel (ModuleName, _permissionLevel);
 		}
 
Index: binary-improvements2/WebServer/src/Web.cs
===================================================================
--- binary-improvements2/WebServer/src/Web.cs	(revision 433)
+++ binary-improvements2/WebServer/src/Web.cs	(revision 434)
@@ -19,5 +19,5 @@
 		
 		private readonly List<AbsHandler> handlers = new List<AbsHandler> ();
-		public readonly List<WebMod> webMods = new List<WebMod> ();
+		public readonly List<WebMod> WebMods = new List<WebMod> ();
 		public readonly ConnectionHandler ConnectionHandler;
 
@@ -134,5 +134,5 @@
 					try {
 						WebMod webMod = new WebMod (this, mod, _useStaticCache);
-						webMods.Add (webMod);
+						WebMods.Add (webMod);
 					} catch (InvalidDataException e) {
 						Log.Error ($"[Web] Could not load webmod from mod {mod.Name}: {e.Message}");
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Command.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Command.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Command.cs	(revision 434)
@@ -91,6 +91,6 @@
 
 		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			if (!TryGetJsonField (_jsonInput, "command", out string commandString)) {
-				SendErrorResult (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_COMMAND");
+			if (!JsonCommons.TryGetJsonField (_jsonInput, "command", out string commandString)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_COMMAND");
 				return;
 			}
@@ -98,5 +98,5 @@
 			WebCommandResult.ResultType responseType = WebCommandResult.ResultType.Full;
 
-			if (TryGetJsonField (_jsonInput, "format", out string formatString)) {
+			if (JsonCommons.TryGetJsonField (_jsonInput, "format", out string formatString)) {
 				if (formatString.EqualsCaseInsensitive ("raw")) {
 					responseType = WebCommandResult.ResultType.Raw;
@@ -115,5 +115,5 @@
 
 			if (command == null) {
-				SendErrorResult (_context, HttpStatusCode.NotFound, _jsonInputData, "UNKNOWN_COMMAND");
+				SendEmptyResponse (_context, HttpStatusCode.NotFound, _jsonInputData, "UNKNOWN_COMMAND");
 				return;
 			}
@@ -122,5 +122,5 @@
 
 			if (_context.PermissionLevel > commandPermissionLevel) {
-				SendErrorResult (_context, HttpStatusCode.Forbidden, _jsonInputData, "NO_PERMISSION");
+				SendEmptyResponse (_context, HttpStatusCode.Forbidden, _jsonInputData, "NO_PERMISSION");
 				return;
 			}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/GameData/Item.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/GameData/Item.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/GameData/Item.cs	(revision 434)
@@ -3,5 +3,5 @@
 using Webserver.Permissions;
 
-namespace Webserver.WebAPI.APIs {
+namespace Webserver.WebAPI.APIs.GameData {
 	[UsedImplicitly]
 	internal class Item : AbsRestApi {
Index: binary-improvements2/WebServer/src/WebAPI/APIs/GameData/Mods.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/GameData/Mods.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/GameData/Mods.cs	(revision 434)
@@ -3,5 +3,5 @@
 using Webserver.Permissions;
 
-namespace Webserver.WebAPI.APIs {
+namespace Webserver.WebAPI.APIs.GameData {
 	[UsedImplicitly]
 	public class Mods : AbsRestApi {
@@ -12,6 +12,6 @@
 			writer.WriteBeginArray ();
 
-			for (int i = 0; i < _parent.webMods.Count; i++) {
-				WebMod webMod = _parent.webMods [i];
+			for (int i = 0; i < _parent.WebMods.Count; i++) {
+				WebMod webMod = _parent.WebMods [i];
 
 				if (i > 0) {
@@ -62,21 +62,21 @@
 			_writer.WriteValueSeparator ();
 			_writer.WritePropertyName ("displayName");
-			JsonCommons.WriteStringOrNull (ref _writer, _webMod.ParentMod.DisplayName);
+			_writer.WriteString (_webMod.ParentMod.DisplayName);
 
 			_writer.WriteValueSeparator ();
 			_writer.WritePropertyName ("description");
-			JsonCommons.WriteStringOrNull (ref _writer, _webMod.ParentMod.Description);
+			_writer.WriteString (_webMod.ParentMod.Description);
 
 			_writer.WriteValueSeparator ();
 			_writer.WritePropertyName ("author");
-			JsonCommons.WriteStringOrNull (ref _writer, _webMod.ParentMod.Author);
+			_writer.WriteString (_webMod.ParentMod.Author);
 
 			_writer.WriteValueSeparator ();
 			_writer.WritePropertyName ("version");
-			JsonCommons.WriteStringOrNull (ref _writer, _webMod.ParentMod.VersionString);
+			_writer.WriteString (_webMod.ParentMod.VersionString);
 
 			_writer.WriteValueSeparator ();
 			_writer.WritePropertyName ("website");
-			JsonCommons.WriteStringOrNull (ref _writer, _webMod.ParentMod.Website);
+			_writer.WriteString (_webMod.ParentMod.Website);
 		}
 
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Log.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Log.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Log.cs	(revision 434)
@@ -55,17 +55,17 @@
 
 				writer.WriteRaw (jsonMsgKey);
-				writer.WriteString (logEntry.message);
+				writer.WriteString (logEntry.Message);
 
 				writer.WriteRaw (jsonTypeKey);
-				writer.WriteString (logEntry.type.ToStringCached ());
+				writer.WriteString (logEntry.Type.ToStringCached ());
 
 				writer.WriteRaw (jsonTraceKey);
-				writer.WriteString (logEntry.trace);
+				writer.WriteString (logEntry.Trace);
 
 				writer.WriteRaw (jsonIsotimeKey);
-				writer.WriteString (logEntry.isoTime);
+				writer.WriteString (logEntry.IsoTime);
 
 				writer.WriteRaw (jsonUptimeKey);
-				writer.WriteString (logEntry.uptime.ToString ());
+				writer.WriteString (logEntry.Uptime.ToString ());
 
 				writer.WriteEndObject ();
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/Blacklist.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/Blacklist.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/Blacklist.cs	(revision 434)
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class Blacklist : AbsRestApi {
+		private const string propertyName = "name";
+		private const string propertyUserId = "userId";
+		private const string propertyBannedUntil = "bannedUntil";
+		private const string propertyBanReason = "banReason";
+
+		private static readonly byte[] jsonKeyName = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyName);
+		private static readonly byte[] jsonKeyUserId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyUserId);
+		private static readonly byte[] jsonKeyBannedUntil = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyBannedUntil);
+		private static readonly byte[] jsonKeyBanReason = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyBanReason);
+
+		private static AdminBlacklist BlacklistInstance => GameManager.Instance.adminTools.Blacklist;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+
+			if (string.IsNullOrEmpty (id)) {
+				writer.WriteBeginArray ();
+
+				bool first = true;
+				foreach (AdminBlacklist.BannedUser ban in BlacklistInstance.GetBanned ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeBan (ref writer, ban);
+				}
+
+				writer.WriteEndArray ();
+
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeBan (ref JsonWriter _writer, AdminBlacklist.BannedUser _ban) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_ban.Name ?? "");
+			_writer.WriteRaw (jsonKeyUserId);
+			JsonCommons.WritePlatformUserIdentifier (ref _writer, _ban.UserIdentifier);
+			_writer.WriteRaw (jsonKeyBannedUntil);
+			JsonCommons.WriteDateTime (ref _writer, _ban.BannedUntil);
+			_writer.WriteRaw (jsonKeyBanReason);
+			_writer.WriteString (_ban.BanReason);
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			if (!TryParseId (_context, _jsonInputData, out PlatformUserIdentifierAbs userId)) {
+				return;
+			}
+
+			if (!JsonCommons.TryReadDateTime (_jsonInput, propertyBannedUntil, out DateTime bannedUntil)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_BANNED_UNTIL");
+				return;
+			}
+
+			JsonCommons.TryGetJsonField (_jsonInput, propertyBanReason, out string banReason);
+
+			JsonCommons.TryGetJsonField (_jsonInput, propertyName, out string name);
+
+			BlacklistInstance.AddBan (name, userId, bannedUntil, banReason);
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			if (!TryParseId (_context, null, out PlatformUserIdentifierAbs userId)) {
+				return;
+			}
+
+			bool removed = BlacklistInstance.RemoveBan (userId);
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		private bool TryParseId (RequestContext _context, byte[] _jsonInputData, out PlatformUserIdentifierAbs _userId) {
+			string id = _context.RequestPath;
+			_userId = default;
+
+			if (string.IsNullOrEmpty (id)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_USER");
+				return false;
+			}
+
+			bool validId = PlatformUserIdentifierAbs.TryFromCombinedString (id, out _userId);
+			if (!validId) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "INVALID_USER");
+			}
+
+			return validId;
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/CommandPermissions.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/CommandPermissions.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/CommandPermissions.cs	(revision 434)
@@ -0,0 +1,104 @@
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class CommandPermissions : AbsRestApi {
+		private const string propertyCommand = "command";
+		private const string propertyPermissionLevel = "permissionLevel";
+
+		private static readonly byte[] jsonKeyCommand = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyCommand);
+		private static readonly byte[] jsonKeyPermissionLevel = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevel);
+
+		private static AdminCommands CommandsInstance => GameManager.Instance.adminTools.Commands;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+			
+			if (string.IsNullOrEmpty (id)) {
+				
+				writer.WriteBeginArray ();
+			
+				bool first = true;
+				foreach ((_, AdminCommands.CommandPermission commandPermission) in CommandsInstance.GetCommands ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeCommandJson (ref writer, commandPermission);
+				}
+
+				writer.WriteEndArray ();
+				
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeCommandJson (ref JsonWriter _writer, AdminCommands.CommandPermission _commandPermission) {
+			_writer.WriteRaw (jsonKeyCommand);
+			_writer.WriteString (_commandPermission.Command);
+			_writer.WriteRaw (jsonKeyPermissionLevel);
+			_writer.WriteInt32 (_commandPermission.PermissionLevel);
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			string id = _context.RequestPath;
+
+			if (string.IsNullOrEmpty (id)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_COMMAND");
+				return;
+			}
+
+			IConsoleCommand cmd = SdtdConsole.Instance.GetCommand (id);
+			if (cmd == null) {
+				SendEmptyResponse (_context, HttpStatusCode.NotFound, _jsonInputData, "INVALID_COMMAND");
+				return;
+			}
+
+			if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPermissionLevel, out int permissionLevel)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PERMISSION_LEVEL");
+				return;
+			}
+
+			CommandsInstance.AddCommand (cmd.GetCommands ()[0], permissionLevel);
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			IConsoleCommand cmd = SdtdConsole.Instance.GetCommand (id);
+			if (cmd == null || !CommandsInstance.IsPermissionDefined (cmd.GetCommands ())) {
+				SendEmptyResponse (_context, HttpStatusCode.NotFound);
+				return;
+			}
+
+			bool removed = CommandsInstance.RemoveCommand (cmd.GetCommands ());
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/PermissionsApiHelpers.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/PermissionsApiHelpers.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/PermissionsApiHelpers.cs	(revision 434)
@@ -0,0 +1,42 @@
+using System;
+using System.Net;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	public static class PermissionsApiHelpers {
+		public static bool TryParseId (RequestContext _context, byte[] _jsonInputData, out PlatformUserIdentifierAbs _userId,
+			out string _groupId) {
+			string id = _context.RequestPath;
+			_userId = default;
+			_groupId = default;
+
+			if (string.IsNullOrEmpty (id)) {
+				WebUtils.SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_USER_OR_GROUP");
+				return false;
+			}
+
+			const string kindUserPrefix = "user/";
+			if (id.StartsWith (kindUserPrefix, StringComparison.Ordinal)) {
+				bool validId = PlatformUserIdentifierAbs.TryFromCombinedString (id.Substring (kindUserPrefix.Length), out _userId);
+				if (!validId) {
+					WebUtils.SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "INVALID_USER");
+				}
+
+				return validId;
+			}
+
+			const string kindGroupPrefix = "group/";
+			if (id.StartsWith (kindGroupPrefix, StringComparison.Ordinal)) {
+				_groupId = id.Substring (kindGroupPrefix.Length);
+				bool validId = _groupId.Length > 0;
+				if (!validId) {
+					WebUtils.SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "INVALID_GROUP");
+				}
+
+				return validId;
+			}
+
+			WebUtils.SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "INVALID_KIND");
+			return false;
+		}
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/RegisterUser.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/RegisterUser.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/RegisterUser.cs	(revision 434)
@@ -9,5 +9,5 @@
 using Webserver.UrlHandlers;
 
-namespace Webserver.WebAPI.APIs {
+namespace Webserver.WebAPI.APIs.Permissions {
 	[UsedImplicitly]
 	public class RegisterUser : AbsRestApi {
@@ -27,10 +27,10 @@
 
 			if (string.IsNullOrEmpty (token)) {
-				SendErrorResult (_context, HttpStatusCode.BadRequest, null, "NO_TOKEN");
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, null, "NO_TOKEN");
 				return;
 			}
 
 			if (!UserRegistrationTokens.TryValidate (token, out UserRegistrationTokens.RegistrationData regData)) {
-				SendErrorResult (_context, HttpStatusCode.NotFound, null, "INVALID_OR_EXPIRED_TOKEN");
+				SendEmptyResponse (_context, HttpStatusCode.NotFound, null, "INVALID_OR_EXPIRED_TOKEN");
 				return;
 			}
@@ -50,31 +50,31 @@
 
 		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			if (!TryGetJsonField (_jsonInput, "token", out string token)) {
-				SendErrorResult (_context, HttpStatusCode.BadRequest, _jsonInputData, "MISSING_TOKEN");
+			if (!JsonCommons.TryGetJsonField (_jsonInput, "token", out string token)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "MISSING_TOKEN");
 				return;
 			}
 
-			if (!TryGetJsonField (_jsonInput, "username", out string username)) {
-				SendErrorResult (_context, HttpStatusCode.BadRequest, _jsonInputData, "MISSING_USERNAME");
+			if (!JsonCommons.TryGetJsonField (_jsonInput, "username", out string username)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "MISSING_USERNAME");
 				return;
 			}
 
-			if (!TryGetJsonField (_jsonInput, "password", out string password)) {
-				SendErrorResult (_context, HttpStatusCode.BadRequest, _jsonInputData, "MISSING_PASSWORD");
+			if (!JsonCommons.TryGetJsonField (_jsonInput, "password", out string password)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "MISSING_PASSWORD");
 				return;
 			}
 
 			if (!UserRegistrationTokens.TryValidate (token, out UserRegistrationTokens.RegistrationData regData)) {
-				SendErrorResult (_context, HttpStatusCode.Unauthorized, null, "INVALID_OR_EXPIRED_TOKEN");
+				SendEmptyResponse (_context, HttpStatusCode.Unauthorized, null, "INVALID_OR_EXPIRED_TOKEN");
 				return;
 			}
 
 			if (!userValidationRegex.IsMatch (username)) {
-				SendErrorResult (_context, HttpStatusCode.Unauthorized, _jsonInputData, "INVALID_USERNAME");
+				SendEmptyResponse (_context, HttpStatusCode.Unauthorized, _jsonInputData, "INVALID_USERNAME");
 				return;
 			}
 			
 			if (!passValidationRegex.IsMatch (password)) {
-				SendErrorResult (_context, HttpStatusCode.Unauthorized, _jsonInputData, "INVALID_PASSWORD");
+				SendEmptyResponse (_context, HttpStatusCode.Unauthorized, _jsonInputData, "INVALID_PASSWORD");
 				return;
 			}
@@ -86,5 +86,5 @@
 				    !PlatformUserIdentifierAbs.Equals (existingMapping.CrossPlatformUser, regData.CrossPlatformUserId)) {
 					// Username already in use by another player
-					SendErrorResult (_context, HttpStatusCode.Unauthorized, _jsonInputData, "DUPLICATE_USERNAME");
+					SendEmptyResponse (_context, HttpStatusCode.Unauthorized, _jsonInputData, "DUPLICATE_USERNAME");
 					return;
 				}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/UserPermissions.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/UserPermissions.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/UserPermissions.cs	(revision 434)
@@ -0,0 +1,159 @@
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class UserPermissions : AbsRestApi {
+		private const string propertyName = "name";
+		private const string propertyUserId = "userId";
+		private const string propertyPermissionLevel = "permissionLevel";
+		private const string propertyGroupId = "groupId";
+		private const string propertyPermissionLevelMods = "permissionLevelMods";
+		private const string propertyPermissionLevelNormal = "permissionLevelNormal";
+
+		private static readonly byte[] jsonKeyUsers = JsonWriter.GetEncodedPropertyNameWithBeginObject ("users");
+		private static readonly byte[] jsonKeyGroups = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator ("groups");
+
+		private static readonly byte[] jsonKeyName = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyName);
+		private static readonly byte[] jsonKeyUserId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyUserId);
+
+		private static readonly byte[] jsonKeyPermissionLevel =
+			JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevel);
+
+		private static readonly byte[] jsonKeyGroupId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyGroupId);
+
+		private static readonly byte[] jsonKeyPermissionLevelMods =
+			JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevelMods);
+
+		private static readonly byte[] jsonKeyPermissionLevelNormal =
+			JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevelNormal);
+
+		private static AdminUsers UsersInstance => GameManager.Instance.adminTools.Users;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+
+			if (string.IsNullOrEmpty (id)) {
+				writer.WriteRaw (jsonKeyUsers);
+				writer.WriteBeginArray ();
+
+				bool first = true;
+				foreach ((_, AdminUsers.UserPermission userPermission) in UsersInstance.GetUsers ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeUserJson (ref writer, userPermission);
+				}
+
+				writer.WriteEndArray ();
+
+				writer.WriteRaw (jsonKeyGroups);
+				writer.WriteBeginArray ();
+
+				first = true;
+				foreach ((_, AdminUsers.GroupPermission groupPermission) in UsersInstance.GetGroups ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeGroupJson (ref writer, groupPermission);
+				}
+
+				writer.WriteEndArray ();
+
+				writer.WriteEndObject ();
+
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeUserJson (ref JsonWriter _writer, AdminUsers.UserPermission _userPermission) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_userPermission.Name ?? "");
+			_writer.WriteRaw (jsonKeyUserId);
+			JsonCommons.WritePlatformUserIdentifier (ref _writer, _userPermission.UserIdentifier);
+			_writer.WriteRaw (jsonKeyPermissionLevel);
+			_writer.WriteInt32 (_userPermission.PermissionLevel);
+			_writer.WriteEndObject ();
+		}
+
+		private void writeGroupJson (ref JsonWriter _writer, AdminUsers.GroupPermission _groupPermission) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_groupPermission.Name ?? "");
+			_writer.WriteRaw (jsonKeyGroupId);
+			_writer.WriteString (_groupPermission.SteamIdGroup);
+			_writer.WriteRaw (jsonKeyPermissionLevelMods);
+			_writer.WriteInt32 (_groupPermission.PermissionLevelMods);
+			_writer.WriteRaw (jsonKeyPermissionLevelNormal);
+			_writer.WriteInt32 (_groupPermission.PermissionLevelNormal);
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			if (!PermissionsApiHelpers.TryParseId (_context, _jsonInputData, out PlatformUserIdentifierAbs userId, out string groupId)) {
+				return;
+			}
+
+			if (userId != null) {
+				if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPermissionLevel, out int permissionLevel)) {
+					SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PERMISSION_LEVEL");
+					return;
+				}
+
+				JsonCommons.TryGetJsonField (_jsonInput, propertyName, out string name);
+
+				UsersInstance.AddUser (name, userId, permissionLevel);
+			} else {
+				if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPermissionLevelMods, out int permissionLevelMods)) {
+					SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PERMISSION_LEVEL_MODS");
+					return;
+				}
+
+				if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPermissionLevelNormal, out int permissionLevelNormal)) {
+					SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PERMISSION_LEVEL_NORMAL");
+					return;
+				}
+
+				JsonCommons.TryGetJsonField (_jsonInput, propertyName, out string name);
+
+				UsersInstance.AddGroup (name, groupId, permissionLevelNormal, permissionLevelMods);
+			}
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			if (!PermissionsApiHelpers.TryParseId (_context, null, out PlatformUserIdentifierAbs userId, out string groupId)) {
+				return;
+			}
+
+			bool removed = userId != null ? UsersInstance.RemoveUser (userId) : UsersInstance.RemoveGroup (groupId);
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebApiTokens.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebApiTokens.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebApiTokens.cs	(revision 434)
@@ -0,0 +1,101 @@
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class WebApiTokens : AbsRestApi {
+		private const string propertyName = "name";
+		private const string propertySecret = "secret";
+		private const string propertyPermissionLevel = "permissionLevel";
+
+		private static readonly byte[] jsonKeyName = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyName);
+		private static readonly byte[] jsonKeySecret = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertySecret);
+		private static readonly byte[] jsonKeyPermissionLevel = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevel);
+
+		private static AdminApiTokens ApiTokensInstance => AdminApiTokens.Instance;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+			
+			if (string.IsNullOrEmpty (id)) {
+				
+				writer.WriteBeginArray ();
+			
+				bool first = true;
+				foreach ((_, AdminApiTokens.ApiToken token) in ApiTokensInstance.GetTokens ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeTokenJson (ref writer, token);
+				}
+
+				writer.WriteEndArray ();
+				
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeTokenJson (ref JsonWriter _writer, AdminApiTokens.ApiToken _token) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_token.Name);
+			_writer.WriteRaw (jsonKeySecret);
+			_writer.WriteString (_token.Secret);
+			_writer.WriteRaw (jsonKeyPermissionLevel);
+			_writer.WriteInt32 (_token.PermissionLevel);
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			string id = _context.RequestPath;
+
+			if (string.IsNullOrEmpty (id)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_NAME");
+				return;
+			}
+
+			if (!JsonCommons.TryGetJsonField (_jsonInput, propertySecret, out string secret)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_SECRET");
+				return;
+			}
+
+			if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPermissionLevel, out int permissionLevel)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PERMISSION_LEVEL");
+				return;
+			}
+
+			ApiTokensInstance.AddToken (id, secret, permissionLevel);
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			bool removed = ApiTokensInstance.RemoveToken (id);
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebModules.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebModules.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebModules.cs	(revision 434)
@@ -0,0 +1,142 @@
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class WebModules : AbsRestApi {
+		private const string propertyModule = "module";
+		private const string propertyPermissionLevelGlobal = "permissionLevelGlobal";
+		private const string propertyPermissionLevelPerMethod = "permissionLevelPerMethod";
+		private const string propertyIsDefault = "isDefault";
+		
+
+		private static readonly byte[] jsonKeyModule = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyModule);
+		private static readonly byte[] jsonKeyPermissionLevelGlobal = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevelGlobal);
+		private static readonly byte[] jsonKeyPermissionLevelPerMethod = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPermissionLevelPerMethod);
+		private static readonly byte[] jsonKeyIsDefault = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyIsDefault);
+
+		private static readonly byte[][] jsonMethodNameKeys;
+
+		static WebModules () {
+			jsonMethodNameKeys = new byte[(int)ERequestMethod.Count][];
+			for (int i = 0; i < jsonMethodNameKeys.Length; i++) {
+				ERequestMethod method = (ERequestMethod)i;
+				jsonMethodNameKeys [i] = JsonWriter.GetEncodedPropertyName (method.ToStringCached ());
+			}
+		}
+
+		private static AdminWebModules ModulesInstance => AdminWebModules.Instance;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+			
+			if (string.IsNullOrEmpty (id)) {
+				
+				writer.WriteBeginArray ();
+			
+				bool first = true;
+				foreach (AdminWebModules.WebModule module in ModulesInstance.GetModules ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeModuleJson (ref writer, module);
+				}
+
+				writer.WriteEndArray ();
+				
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeModuleJson (ref JsonWriter _writer, AdminWebModules.WebModule _module) {
+			_writer.WriteRaw (jsonKeyModule);
+			_writer.WriteString (_module.Name);
+			_writer.WriteRaw (jsonKeyPermissionLevelGlobal);
+			_writer.WriteInt32 (_module.LevelGlobal);
+			_writer.WriteRaw (jsonKeyPermissionLevelPerMethod);
+			
+			_writer.WriteBeginObject ();
+
+			if (_module.LevelPerMethod != null) {
+				bool first = true;
+				for (int iMethod = 0; iMethod < _module.LevelPerMethod.Length; iMethod++) {
+					int methodLevel = _module.LevelPerMethod [iMethod];
+						
+					if (methodLevel == AdminWebModules.MethodLevelNotSupported) {
+						continue;
+					}
+
+					if (!first) {
+						_writer.WriteValueSeparator ();
+					}
+
+					first = false;
+						
+					_writer.WriteRaw (jsonMethodNameKeys [iMethod]);
+					if (methodLevel == AdminWebModules.MethodLevelInheritGlobal) {
+						_writer.WriteString ("inherit");
+					} else {
+						_writer.WriteInt32 (methodLevel);
+					}
+				}
+			}
+
+			_writer.WriteEndObject ();
+			
+			_writer.WriteRaw (jsonKeyIsDefault);
+			_writer.WriteBoolean (_module.IsDefault);
+			
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			string id = _context.RequestPath;
+
+			// TODO
+			
+			if (string.IsNullOrEmpty (id)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_MODULE");
+				return;
+			}
+
+			if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPermissionLevelGlobal, out int permissionLevel)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PERMISSION_LEVEL");
+				return;
+			}
+
+			// ModulesInstance.AddModule (id, permissionLevel);
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			bool removed = ModulesInstance.RemoveModule (id);
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebUsers.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebUsers.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/WebUsers.cs	(revision 434)
@@ -0,0 +1,127 @@
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class WebUsers : AbsRestApi {
+		private const string propertyName = "name";
+		private const string propertyPassword = "password";
+		private const string propertyPlatformUserId = "platformUserId";
+		private const string propertyCrossplatformUserId = "crossplatformUserId";
+
+		private static readonly byte[] jsonKeyName = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyName);
+		private static readonly byte[] jsonKeyPlatformUserId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyPlatformUserId);
+		private static readonly byte[] jsonKeyCrossplatformUserId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyCrossplatformUserId);
+
+		private static AdminWebUsers WebUsersInstance => AdminWebUsers.Instance;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+
+			if (string.IsNullOrEmpty (id)) {
+				writer.WriteBeginArray ();
+
+				bool first = true;
+				foreach ((_, AdminWebUsers.WebUser user) in WebUsersInstance.GetUsers ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeUserJson (ref writer, user);
+				}
+
+				writer.WriteEndArray ();
+
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeUserJson (ref JsonWriter _writer, AdminWebUsers.WebUser _user) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_user.Name ?? "");
+			_writer.WriteRaw (jsonKeyPlatformUserId);
+			JsonCommons.WritePlatformUserIdentifier (ref _writer, _user.PlatformUser);
+			_writer.WriteRaw (jsonKeyCrossplatformUserId);
+			JsonCommons.WritePlatformUserIdentifier (ref _writer, _user.CrossPlatformUser);
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			if (!TryParseName (_context, _jsonInputData, out string userName)) {
+				return;
+			}
+
+			if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPassword, out string password)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_OR_INVALID_PASSWORD");
+				return;
+			}
+
+			if (!JsonCommons.TryGetJsonField (_jsonInput, propertyPlatformUserId, out IDictionary<string, object> userIdField)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_PLATFORM_USER_ID");
+				return;
+			}
+
+			if (!JsonCommons.TryReadPlatformUserIdentifier (userIdField, out PlatformUserIdentifierAbs platformUserId)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "INVALID_PLATFORM_USER_ID");
+				return;
+			}
+
+			PlatformUserIdentifierAbs crossplatformUserId = null;
+			
+			if (JsonCommons.TryGetJsonField (_jsonInput, propertyCrossplatformUserId, out userIdField)) {
+				if (!JsonCommons.TryReadPlatformUserIdentifier (userIdField, out crossplatformUserId)) {
+					SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "INVALID_CROSSPLATFORM_USER_ID");
+					return;
+				}
+			}
+
+			WebUsersInstance.AddUser (userName, password, platformUserId, crossplatformUserId);
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			if (!TryParseName (_context, null, out string userName)) {
+				return;
+			}
+
+			bool removed = WebUsersInstance.RemoveUser (userName);
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		private bool TryParseName (RequestContext _context, byte[] _jsonInputData, out string _userName) {
+			string id = _context.RequestPath;
+			_userName = default;
+
+			if (string.IsNullOrEmpty (id)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_USERNAME");
+				return false;
+			}
+
+			_userName = id;
+			return true;
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/Whitelist.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/Whitelist.cs	(revision 434)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/Permissions/Whitelist.cs	(revision 434)
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using System.Net;
+using JetBrains.Annotations;
+using Utf8Json;
+using Webserver.Permissions;
+
+namespace Webserver.WebAPI.APIs.Permissions {
+	[UsedImplicitly]
+	public class Whitelist : AbsRestApi {
+		private const string propertyName = "name";
+		private const string propertyUserId = "userId";
+		private const string propertyGroupId = "groupId";
+
+		private static readonly byte[] jsonKeyUsers = JsonWriter.GetEncodedPropertyNameWithBeginObject ("users");
+		private static readonly byte[] jsonKeyGroups = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator ("groups");
+
+		private static readonly byte[] jsonKeyName = JsonWriter.GetEncodedPropertyNameWithBeginObject (propertyName);
+		private static readonly byte[] jsonKeyUserId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyUserId);
+
+		private static readonly byte[] jsonKeyGroupId = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator (propertyGroupId);
+
+		private static AdminWhitelist WhitelistInstance => GameManager.Instance.adminTools.Whitelist;
+
+		protected override void HandleRestGet (RequestContext _context) {
+			string id = _context.RequestPath;
+
+			PrepareEnvelopedResult (out JsonWriter writer);
+
+			if (string.IsNullOrEmpty (id)) {
+				writer.WriteRaw (jsonKeyUsers);
+				writer.WriteBeginArray ();
+
+				bool first = true;
+				foreach ((_, AdminWhitelist.WhitelistUser user) in WhitelistInstance.GetUsers ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeUserJson (ref writer, user);
+				}
+
+				writer.WriteEndArray ();
+
+				writer.WriteRaw (jsonKeyGroups);
+				writer.WriteBeginArray ();
+
+				first = true;
+				foreach ((_, AdminWhitelist.WhitelistGroup group) in WhitelistInstance.GetGroups ()) {
+					if (!first) {
+						writer.WriteValueSeparator ();
+					}
+
+					first = false;
+
+					writeGroupJson (ref writer, group);
+				}
+
+				writer.WriteEndArray ();
+
+				writer.WriteEndObject ();
+
+				SendEnvelopedResult (_context, ref writer);
+				return;
+			}
+
+			writer.WriteRaw (WebUtils.JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, HttpStatusCode.BadRequest);
+		}
+
+		private void writeUserJson (ref JsonWriter _writer, AdminWhitelist.WhitelistUser _userPermission) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_userPermission.Name ?? "");
+			_writer.WriteRaw (jsonKeyUserId);
+			JsonCommons.WritePlatformUserIdentifier (ref _writer, _userPermission.UserIdentifier);
+			_writer.WriteEndObject ();
+		}
+
+		private void writeGroupJson (ref JsonWriter _writer, AdminWhitelist.WhitelistGroup _groupPermission) {
+			_writer.WriteRaw (jsonKeyName);
+			_writer.WriteString (_groupPermission.Name ?? "");
+			_writer.WriteRaw (jsonKeyGroupId);
+			_writer.WriteString (_groupPermission.SteamIdGroup);
+			_writer.WriteEndObject ();
+		}
+
+		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			if (!PermissionsApiHelpers.TryParseId (_context, _jsonInputData, out PlatformUserIdentifierAbs userId, out string groupId)) {
+				return;
+			}
+
+			JsonCommons.TryGetJsonField (_jsonInput, propertyName, out string name);
+
+			if (userId != null) {
+				WhitelistInstance.AddUser (name, userId);
+			} else {
+				WhitelistInstance.AddGroup (name, groupId);
+			}
+
+			SendEmptyResponse (_context, HttpStatusCode.Created);
+		}
+
+		protected override void HandleRestDelete (RequestContext _context) {
+			if (!PermissionsApiHelpers.TryParseId (_context, null, out PlatformUserIdentifierAbs userId, out string groupId)) {
+				return;
+			}
+
+			bool removed = userId != null ? WhitelistInstance.RemoveUser (userId) : WhitelistInstance.RemoveGroup (groupId);
+
+			SendEmptyResponse (_context, removed ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
+		}
+
+		protected override bool AllowPostWithId => true;
+
+		public override int[] DefaultMethodPermissionLevels () => new[] {
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelInheritGlobal,
+			AdminWebModules.MethodLevelNotSupported,
+			AdminWebModules.MethodLevelInheritGlobal
+		};
+	}
+}
Index: binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Animal.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Animal.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Animal.cs	(revision 434)
@@ -4,5 +4,5 @@
 using Webserver.LiveData;
 
-namespace Webserver.WebAPI.APIs {
+namespace Webserver.WebAPI.APIs.WorldState {
 	[UsedImplicitly]
 	internal class Animal : AbsRestApi {
Index: binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Hostile.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Hostile.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Hostile.cs	(revision 434)
@@ -4,5 +4,5 @@
 using Webserver.LiveData;
 
-namespace Webserver.WebAPI.APIs {
+namespace Webserver.WebAPI.APIs.WorldState {
 	[UsedImplicitly]
 	internal class Hostile : AbsRestApi {
Index: binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Player.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Player.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/APIs/WorldState/Player.cs	(revision 434)
@@ -6,5 +6,5 @@
 using Webserver.Permissions;
 
-namespace Webserver.WebAPI.APIs {
+namespace Webserver.WebAPI.APIs.WorldState {
 	[UsedImplicitly]
 	public class Player : AbsRestApi {
@@ -216,6 +216,6 @@
 
 		protected override void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			if (!TryGetJsonField (_jsonInput, "command", out string commandString)) {
-				SendErrorResult (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_COMMAND");
+			if (!JsonCommons.TryGetJsonField (_jsonInput, "command", out string commandString)) {
+				SendEmptyResponse (_context, HttpStatusCode.BadRequest, _jsonInputData, "NO_COMMAND");
 				return;
 			}
@@ -223,5 +223,5 @@
 			WebCommandResult.ResultType responseType = WebCommandResult.ResultType.Full;
 
-			if (TryGetJsonField (_jsonInput, "format", out string formatString)) {
+			if (JsonCommons.TryGetJsonField (_jsonInput, "format", out string formatString)) {
 				if (formatString.EqualsCaseInsensitive ("raw")) {
 					responseType = WebCommandResult.ResultType.Raw;
@@ -240,5 +240,5 @@
 
 			if (command == null) {
-				SendErrorResult (_context, HttpStatusCode.NotFound, _jsonInputData, "UNKNOWN_COMMAND");
+				SendEmptyResponse (_context, HttpStatusCode.NotFound, _jsonInputData, "UNKNOWN_COMMAND");
 				return;
 			}
@@ -247,5 +247,5 @@
 
 			if (_context.PermissionLevel > commandPermissionLevel) {
-				SendErrorResult (_context, HttpStatusCode.Forbidden, _jsonInputData, "NO_PERMISSION");
+				SendEmptyResponse (_context, HttpStatusCode.Forbidden, _jsonInputData, "NO_PERMISSION");
 				return;
 			}
Index: binary-improvements2/WebServer/src/WebAPI/AbsRestApi.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/AbsRestApi.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/AbsRestApi.cs	(revision 434)
@@ -44,5 +44,5 @@
 					jsonDeserializeSampler.End ();
 
-					SendErrorResult (_context, HttpStatusCode.BadRequest, null, "INVALID_BODY", e);
+					SendEmptyResponse (_context, HttpStatusCode.BadRequest, null, "INVALID_BODY", e);
 					return;
 				}
@@ -53,5 +53,5 @@
 					case ERequestMethod.GET:
 						if (inputJson != null) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, jsonInputData, "GET_WITH_BODY");
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, jsonInputData, "GET_WITH_BODY");
 							return;
 						}
@@ -60,11 +60,11 @@
 						return;
 					case ERequestMethod.POST:
-						if (!string.IsNullOrEmpty (_context.RequestPath)) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, jsonInputData, "POST_WITH_ID");
+						if (!AllowPostWithId && !string.IsNullOrEmpty (_context.RequestPath)) {
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, jsonInputData, "POST_WITH_ID");
 							return;
 						}
 
 						if (inputJson == null) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, null, "POST_WITHOUT_BODY");
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, null, "POST_WITHOUT_BODY");
 							return;
 						}
@@ -74,10 +74,10 @@
 					case ERequestMethod.PUT:
 						if (string.IsNullOrEmpty (_context.RequestPath)) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, jsonInputData, "PUT_WITHOUT_ID");
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, jsonInputData, "PUT_WITHOUT_ID");
 							return;
 						}
 
 						if (inputJson == null) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, null, "PUT_WITHOUT_BODY");
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, null, "PUT_WITHOUT_BODY");
 							return;
 						}
@@ -87,10 +87,10 @@
 					case ERequestMethod.DELETE:
 						if (string.IsNullOrEmpty (_context.RequestPath)) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, jsonInputData, "DELETE_WITHOUT_ID");
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, jsonInputData, "DELETE_WITHOUT_ID");
 							return;
 						}
 
 						if (inputJson != null) {
-							SendErrorResult (_context, HttpStatusCode.BadRequest, null, "DELETE_WITH_BODY");
+							SendEmptyResponse (_context, HttpStatusCode.BadRequest, null, "DELETE_WITH_BODY");
 							return;
 						}
@@ -99,26 +99,26 @@
 						return;
 					default:
-						SendErrorResult (_context, HttpStatusCode.BadRequest, null, "INVALID_METHOD");
+						SendEmptyResponse (_context, HttpStatusCode.BadRequest, null, "INVALID_METHOD");
 						return;
 				}
 			} catch (Exception e) {
-				SendErrorResult (_context, HttpStatusCode.InternalServerError, jsonInputData, "ERROR_PROCESSING", e);
+				SendEmptyResponse (_context, HttpStatusCode.InternalServerError, jsonInputData, "ERROR_PROCESSING", e);
 			}
 		}
 
 		protected virtual void HandleRestGet (RequestContext _context) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
+			SendEmptyResponse (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
 		}
 
 		protected virtual void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
+			SendEmptyResponse (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
 		}
 
 		protected virtual void HandleRestPut (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
+			SendEmptyResponse (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
 		}
 
 		protected virtual void HandleRestDelete (RequestContext _context) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
+			SendEmptyResponse (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
 		}
 
@@ -126,17 +126,21 @@
 			AdminWebModules.WebModule module = AdminWebModules.Instance.GetModule (CachedApiModuleName);
 
-			if (module.LevelPerMethod != null) {
-				int perMethodLevel = module.LevelPerMethod [(int)_context.Method];
-				if (perMethodLevel == AdminWebModules.MethodLevelNotSupported) {
-					return false;
-				}
+			if (module.LevelPerMethod == null) {
+				return module.LevelGlobal >= _context.PermissionLevel;
+			}
 
-				if (perMethodLevel != AdminWebModules.MethodLevelInheritGlobal) {
-					return perMethodLevel >= _context.PermissionLevel;
-				}
+			int perMethodLevel = module.LevelPerMethod [(int)_context.Method];
+			if (perMethodLevel == AdminWebModules.MethodLevelNotSupported) {
+				return false;
+			}
+
+			if (perMethodLevel != AdminWebModules.MethodLevelInheritGlobal) {
+				return perMethodLevel >= _context.PermissionLevel;
 			}
 
 			return module.LevelGlobal >= _context.PermissionLevel;
 		}
+
+		protected virtual bool AllowPostWithId => false;
 
 		/// <summary>
@@ -154,13 +158,4 @@
 #region Helpers
 
-		protected static readonly byte[] JsonEmptyData;
-		
-		static AbsRestApi () {
-			JsonWriter writer = new JsonWriter ();
-			writer.WriteBeginArray ();
-			writer.WriteEndArray ();
-			JsonEmptyData = writer.ToUtf8ByteArray ();
-		}
-
 		protected static void PrepareEnvelopedResult (out JsonWriter _writer) {
 			WebUtils.PrepareEnvelopedResult (out _writer);
@@ -173,67 +168,9 @@
 		}
 
-		protected static void SendErrorResult (RequestContext _context, HttpStatusCode _statusCode, byte[] _jsonInputData = null, string _errorCode = null, Exception _exception = null) {
+		protected static void SendEmptyResponse (RequestContext _context, HttpStatusCode _statusCode = HttpStatusCode.OK, byte[] _jsonInputData = null, string _errorCode = null, Exception _exception = null) {
 			PrepareEnvelopedResult (out JsonWriter writer);
-			writer.WriteRaw (JsonEmptyData);
+			writer.WriteRaw (WebUtils.JsonEmptyData);
 			SendEnvelopedResult (_context, ref writer, _statusCode, _jsonInputData, _errorCode, _exception);
 		}
-
-		protected static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out int _value) {
-			_value = default;
-			
-			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
-				return false;
-			}
-
-			if (fieldNode is not double value) {
-				return false;
-			}
-
-			try {
-				_value = (int)value;
-				return true;
-			} catch (Exception) {
-				return false;
-			}
-		}
-
-		protected static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out double _value) {
-			_value = default;
-			
-			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
-				return false;
-			}
-
-			if (fieldNode is not double value) {
-				return false;
-			}
-
-			try {
-				_value = value;
-				return true;
-			} catch (Exception) {
-				return false;
-			}
-		}
-
-		protected static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out string _value) {
-			_value = default;
-			
-			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
-				return false;
-			}
-
-			if (fieldNode is not string value) {
-				return false;
-			}
-
-			try {
-				_value = value;
-				return true;
-			} catch (Exception) {
-				return false;
-			}
-		}
-		
 
 #endregion
Index: binary-improvements2/WebServer/src/WebAPI/JsonCommons.cs
===================================================================
--- binary-improvements2/WebServer/src/WebAPI/JsonCommons.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebAPI/JsonCommons.cs	(revision 434)
@@ -1,3 +1,5 @@
 using System;
+using System.Collections.Generic;
+using System.Globalization;
 using UnityEngine;
 using Utf8Json;
@@ -57,13 +59,115 @@
 		}
 
+		public static bool TryReadPlatformUserIdentifier (IDictionary<string, object> _jsonInput, out PlatformUserIdentifierAbs _userIdentifier) {
+			if (TryGetJsonField (_jsonInput, "combinedString", out string combinedString)) {
+				_userIdentifier = PlatformUserIdentifierAbs.FromCombinedString (combinedString, false);
+				if (_userIdentifier != null) {
+					return true;
+				}
+			}
+			
+			if (!TryGetJsonField (_jsonInput, "platformId", out string platformId)) {
+				_userIdentifier = default;
+				return false;
+			}
+
+			if (!TryGetJsonField (_jsonInput, "userId", out string userId)) {
+				_userIdentifier = default;
+				return false;
+			}
+
+			_userIdentifier = PlatformUserIdentifierAbs.FromPlatformAndId (platformId, userId, false);
+			return _userIdentifier != null;
+		}
+
 		public static void WriteDateTime (ref JsonWriter _writer, DateTime _dateTime) {
 			_writer.WriteString (_dateTime.ToString ("o"));
 		}
 
-		public static void WriteStringOrNull (ref JsonWriter _writer, string _string) {
-			if (_string == null) {
-				_writer.WriteNull ();
-			} else {
-				_writer.WriteString (_string);
+		public static bool TryReadDateTime (IDictionary<string, object> _jsonInput, string _fieldName, out DateTime _result) {
+			_result = default;
+			
+			if (!TryGetJsonField (_jsonInput, _fieldName, out string dateTimeString)) {
+				return false;
+			}
+
+			return DateTime.TryParse (dateTimeString, null, DateTimeStyles.RoundtripKind, out _result);
+		}
+
+
+		public static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out int _value) {
+			_value = default;
+			
+			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
+				return false;
+			}
+
+			if (fieldNode is not double value) {
+				return false;
+			}
+
+			try {
+				_value = (int)value;
+				return true;
+			} catch (Exception) {
+				return false;
+			}
+		}
+
+		public static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out double _value) {
+			_value = default;
+			
+			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
+				return false;
+			}
+
+			if (fieldNode is not double value) {
+				return false;
+			}
+
+			try {
+				_value = value;
+				return true;
+			} catch (Exception) {
+				return false;
+			}
+		}
+
+		public static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out string _value) {
+			_value = default;
+			
+			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
+				return false;
+			}
+
+			if (fieldNode is not string value) {
+				return false;
+			}
+
+			try {
+				_value = value;
+				return true;
+			} catch (Exception) {
+				return false;
+			}
+		}
+
+		public static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName,
+			out IDictionary<string, object> _value) {
+			_value = default;
+			
+			if (!_jsonObject.TryGetValue (_fieldName, out object fieldNode)) {
+				return false;
+			}
+
+			if (fieldNode is not IDictionary<string, object> value) {
+				return false;
+			}
+
+			try {
+				_value = value;
+				return true;
+			} catch (Exception) {
+				return false;
 			}
 		}
Index: binary-improvements2/WebServer/src/WebConnection.cs
===================================================================
--- binary-improvements2/WebServer/src/WebConnection.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebConnection.cs	(revision 434)
@@ -3,5 +3,4 @@
 using System.Net;
 using UnityEngine;
-using Webserver.Permissions;
 
 namespace Webserver {
Index: binary-improvements2/WebServer/src/WebMod.cs
===================================================================
--- binary-improvements2/WebServer/src/WebMod.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebMod.cs	(revision 434)
@@ -20,19 +20,21 @@
 			IsWebMod = Directory.Exists (folder);
 
-			if (IsWebMod) {
-				string urlWebModBase = $"{modsBaseUrl}{_parentMod.FolderName}/";
+			if (!IsWebMod) {
+				return;
+			}
 
-				ReactBundle = $"{folder}/{reactBundleName}";
-				ReactBundle = File.Exists (ReactBundle) ? $"{urlWebModBase}{reactBundleName}" : null;
+			string urlWebModBase = $"{modsBaseUrl}{_parentMod.FolderName}/";
 
-				CssPath = $"{folder}/{stylingFileName}";
-				CssPath = File.Exists (CssPath) ? $"{urlWebModBase}{stylingFileName}" : null;
+			ReactBundle = $"{folder}/{reactBundleName}";
+			ReactBundle = File.Exists (ReactBundle) ? $"{urlWebModBase}{reactBundleName}" : null;
 
-				_parentWeb.RegisterPathHandler (urlWebModBase, new StaticHandler (
-					folder,
-					_useStaticCache ? new SimpleCache () : new DirectAccess (),
-					false)
-				);
-			}
+			CssPath = $"{folder}/{stylingFileName}";
+			CssPath = File.Exists (CssPath) ? $"{urlWebModBase}{stylingFileName}" : null;
+
+			_parentWeb.RegisterPathHandler (urlWebModBase, new StaticHandler (
+				folder,
+				_useStaticCache ? new SimpleCache () : new DirectAccess (),
+				false)
+			);
 		}
 	}
Index: binary-improvements2/WebServer/src/WebUtils.cs
===================================================================
--- binary-improvements2/WebServer/src/WebUtils.cs	(revision 433)
+++ binary-improvements2/WebServer/src/WebUtils.cs	(revision 434)
@@ -4,4 +4,5 @@
 using System.Net;
 using System.Text;
+using UnityEngine.Profiling;
 using Utf8Json;
 using Webserver.WebAPI;
@@ -11,4 +12,11 @@
 namespace Webserver {
 	public static class WebUtils {
+		static WebUtils () {
+			JsonWriter writer = new JsonWriter ();
+			writer.WriteBeginArray ();
+			writer.WriteEndArray ();
+			JsonEmptyData = writer.ToUtf8ByteArray ();
+		}
+
 		// ReSharper disable once MemberCanBePrivate.Global
 		public const string MimePlain = "text/plain";
@@ -17,6 +25,6 @@
 		public const string MimeJson = "application/json";
 		
-		private static readonly UnityEngine.Profiling.CustomSampler envelopeBuildSampler = UnityEngine.Profiling.CustomSampler.Create ("JSON_EnvelopeBuilding");
-		private static readonly UnityEngine.Profiling.CustomSampler netWriteSampler = UnityEngine.Profiling.CustomSampler.Create ("JSON_Write");
+		private static readonly CustomSampler envelopeBuildSampler = CustomSampler.Create ("JSON_EnvelopeBuilding");
+		private static readonly CustomSampler netWriteSampler = CustomSampler.Create ("JSON_Write");
 
 		public static void WriteText (HttpListenerResponse _resp, string _text, HttpStatusCode _statusCode = HttpStatusCode.OK, string _mimeType = null) {
@@ -56,5 +64,6 @@
 		}
 		
-		
+		public static readonly byte[] JsonEmptyData;
+
 		private static readonly byte[] jsonRawDataKey = JsonWriter.GetEncodedPropertyNameWithBeginObject ("data"); // {"data":
 
@@ -117,4 +126,11 @@
 		}
 
+		public static void SendEmptyResponse (RequestContext _context, HttpStatusCode _statusCode = HttpStatusCode.OK, byte[] _jsonInputData = null, string _errorCode = null, Exception _exception = null) {
+			PrepareEnvelopedResult (out JsonWriter writer);
+			writer.WriteRaw (JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, _statusCode, _jsonInputData, _errorCode, _exception);
+		}
+
+
 		public static bool TryGetValue (this NameValueCollection _nameValueCollection, string _name, out string _result) {
 			_result = _nameValueCollection [_name];
