fix: correct 2023-03-21 client wire contracts across the API and notify surfaces

Audits every route the March-2023 client can call against what this server
actually serves, and fixes the defects that were breaking gameplay. Adds
docs/recroom-2023-client-api-complete.md as the reference: ~500 route
literals across 13 subsystems, with verb, request/response shape, and a
per-subsystem diff. 315 defects catalogued; the ones fixed here are listed
in its "Already fixed" table.

Path coverage was never the problem — 465 of 506 routes already had
handlers. The failures were in shape, and they cluster into four causes:

- Wrong verb. The verb is a BestHTTP.HTTPMethods ordinal (GET=0 .. PATCH=5),
  confirmed from that enum's static constructor. Routes registered POST-only
  while the client PUTs returned 405, which made room consumables neither
  buyable nor consumable and blocked every room-key edit.
- Bare scalar wrapped in an object. The client reads `1000`, not
  `{"Limit":1000}`; the issuing method signature says which. Wrapping makes
  the deserializer throw and the feature dies silently.
- [FromBody] vs form-urlencoded. The client sends nearly every write as form
  data, which an [ApiController] action bound [FromBody] rejects with 415
  before the handler runs. This alone broke all 13 club member-management
  endpoints. Adds Binding/FormOrJsonModelBinder so this is fixed once.
- Notify dispatch keys. Both enum overloads of the client's RegisterHandler
  lower the id via Int32.ToString() and register only that numeric key, and
  lookup is a case-sensitive TryGetValue that returns silently on a miss. We
  were sending the enum NAME for most ids, so friend-request updates, forced
  logout, maintenance broadcasts, live balance and consumable updates, and
  invention moderation pushes all evaporated with no error anywhere.

Notable behavioural bugs fixed:

- Editing a club announcement deleted it: PUT was a third binding on the
  delete handler. Split apart, and the missing create route added.
- leaderboard/CheckAndSetStat did not exist. The 2023 client has no SetStat
  literal at all, so every leaderboard score it wrote was discarded.
- Setting a club clubhouse cleared it: the handler read roomId from the query
  while the client sends it in the body, then wrote the resulting null.
- Club join requests were inverted (the enum is Open=0, InviteOnly=1,
  AskToJoin=2), so ask-to-join clubs rejected everyone and invite-only clubs
  quietly admitted them.
- Room bans were pushed as id 24, for which the client registers no handler;
  they now go out as ModerationKick with IsBan, so the ban takes effect
  immediately instead of at next login.
- Room-key ownership queries answered for the wrong player, and "has anyone
  bought this key" asked "did I buy it" — always false for the creator.

Also persists settings that were previously acknowledged and dropped
(playlist accessibility/restrictions/level-voting/warning, per-player status
visibility, VR movement mode and avoid-juniors, custom room codes), which is
what the new columns in the migration are for.

One test asserted the old nested PriceAndCurrency response shape; the
client's consumable formatter has no such key and reads Price and
PurchaseCurrencyId flat, so the test was corrected alongside the handler.
This commit is contained in:
Alexa
2026-07-27 23:27:23 +03:00
parent 674a3fc257
commit 327dcb789c
39 changed files with 9594 additions and 200 deletions
@@ -272,6 +272,20 @@ public class ClubsController(ClubService clubs) : ControllerBase
{
if (HttpMethods.IsPut(Request.Method) || HttpMethods.IsDelete(Request.Method))
{
// The client sends roomId in the form-urlencoded BODY, not the query
// string. Reading only the query bound null, and the PUT branch then
// wrote that null straight through — so "set clubhouse" silently
// CLEARED the clubhouse instead of setting it.
if (roomId is null && Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "roomId", "RoomId" })
if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0)
{
roomId = fromForm;
break;
}
}
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c =>
@@ -368,7 +382,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/modifydetails")]
[HttpPut("/club/{clubId:long}/modifydetails")]
[Authorize]
public async Task<IActionResult> ClubModify(long clubId, [FromBody] ModifyClubRequest req)
public async Task<IActionResult> ClubModify(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] ModifyClubRequest req)
{
try
{
@@ -396,7 +410,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/mainimage")]
[HttpPut("/club/{clubId:long}/mainimage")]
[Authorize]
public async Task<IActionResult> ClubMainImage(long clubId, [FromBody] MainImageRequest req)
public async Task<IActionResult> ClubMainImage(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req)
{
try
{
@@ -417,7 +431,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
public async Task<IActionResult> ClubAdditionalImage(long clubId, int slot, [FromBody] MainImageRequest req)
public async Task<IActionResult> ClubAdditionalImage(long clubId, int slot, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -450,10 +464,15 @@ public class ClubsController(ClubService clubs) : ControllerBase
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
// Joinability enum, confirmed from the 2023 dump (enum FEHIHCMDOLN):
// Open = 0, InviteOnly = 1, AskToJoin = 2.
// This used to treat 1 as request-to-join and 2 as invite-only, i.e.
// exactly backwards: "Ask to join" clubs rejected every request while
// invite-only clubs quietly accepted them.
var perms = club.Joinability switch
{
0 => 0, // Open: instant member
1 => 128, // RequestToJoin: pending
2 => 128, // AskToJoin: pending approval
_ => -1, // InviteOnly: rejected
};
if (perms == -1) return Forbid();
@@ -503,7 +522,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/invite")]
[HttpPut("/club/{clubId:long}/members/invite")]
[Authorize]
public async Task<IActionResult> MemberInvite(long clubId, [FromBody] MemberInviteRequest req)
public async Task<IActionResult> MemberInvite(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberInviteRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -524,7 +543,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/invitemembers")]
[Authorize]
public async Task<IActionResult> MemberInviteMembers(long clubId, [FromBody] InviteMembersRequest req)
public async Task<IActionResult> MemberInviteMembers(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] InviteMembersRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
@@ -568,7 +587,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
/// target's row from Pending to Member.</summary>
[HttpPost("/club/{clubId:long}/members/acceptrequest")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequest(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberAcceptRequest(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -588,7 +607,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/acceptrequests")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequests(long clubId, [FromBody] BulkTargetRequest req)
public async Task<IActionResult> MemberAcceptRequests(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
@@ -603,7 +622,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/denyrequest")]
[Authorize]
public async Task<IActionResult> MemberDenyRequest(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberDenyRequest(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -614,7 +633,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/denyrequests")]
[Authorize]
public async Task<IActionResult> MemberDenyRequests(long clubId, [FromBody] BulkTargetRequest req)
public async Task<IActionResult> MemberDenyRequests(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
@@ -625,7 +644,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/remove")]
[Authorize]
public async Task<IActionResult> MemberRemove(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberRemove(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -643,7 +662,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
/// request-to-join flow can refuse them later.</summary>
[HttpPost("/club/{clubId:long}/members/ban")]
[Authorize]
public async Task<IActionResult> MemberBan(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberBan(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -658,7 +677,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/unban")]
[Authorize]
public async Task<IActionResult> MemberUnban(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberUnban(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -674,7 +693,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/changetype")]
[HttpPut("/club/{clubId:long}/members/changetype")]
[Authorize]
public async Task<IActionResult> MemberChangeType(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberChangeType(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -759,7 +778,6 @@ public class ClubsController(ClubService clubs) : ControllerBase
/// double-fires are safe.</summary>
[HttpDelete("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPost("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
{
@@ -767,6 +785,72 @@ public class ClubsController(ClubService clubs) : ControllerBase
return Ok();
}
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
/// an announcement.
///
/// PUT used to be a third binding on <see cref="AnnouncementDelete"/>, so
/// editing an announcement silently DELETED it — the edit form's save
/// button destroyed the post it was editing. It is its own handler now.</summary>
[HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementEdit(long clubId, long announcementId)
{
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
return Forbid();
return Ok();
}
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
/// announcement. The client posts the fields to the COLLECTION url and
/// reads the response as a bare Int64 (the new announcement id). No POST
/// was registered at that template, so posting a club announcement 404'd.</summary>
[HttpPost("/announcements/club/{clubId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementCreate(long clubId)
{
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
var id = await clubs.CreateAnnouncementAsync(
clubId, Me, title ?? string.Empty, body ?? string.Empty, imageName ?? string.Empty);
if (id is null) return Forbid();
return Content(id.Value.ToString(), "application/json");
}
/// <summary>Announcement fields as the client sends them: form-urlencoded,
/// with a JSON body accepted as a fallback. Null means "not supplied", so
/// an edit can leave a field alone.</summary>
private async Task<(string? Title, string? Body, string? ImageName)> ReadAnnouncementFieldsAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
string? F(params string[] keys)
{
foreach (var k in keys)
if (form.TryGetValue(k, out var v) && v.Count > 0) return v.ToString();
return null;
}
return (F("title", "Title"), F("body", "Body"), F("imageName", "ImageName"));
}
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
string? P(params string[] keys)
{
foreach (var k in keys)
if (doc.RootElement.TryGetProperty(k, out var v) &&
v.ValueKind == System.Text.Json.JsonValueKind.String) return v.GetString();
return null;
}
return (P("title", "Title"), P("body", "Body"), P("imageName", "ImageName"));
}
catch (System.Text.Json.JsonException) { return (null, null, null); }
}
// ── Wire-shape mappers ──────────────────────────────────────────
/// <summary>