fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client

Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.

Presence
- matchmake/none means "online, in no room" (its only caller is
  TryGoToOfflineRoom, which ignores the reply). The server used to keep the
  old room or invent a dorm, so the next heartbeat looked out of sync and
  the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
  it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
  joins, matchmake/none, logout, failed joins, visibility changes, SignalR
  connect/disconnect. isOnline is sent on its own; pushes never go to the
  subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
  is stored and honoured by GET /player and by the live pushes.

Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
  404; added with the client's real form keys and persisted (new Players
  columns plus migration). Every account read now returns them, and edits
  push AccountUpdate to everyone.

Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
  slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
  client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
  form bodies; lockeditems returns the full owned-item shape; colourways no
  longer share their base item's AvatarItemId.

RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
  An empty 200 showed "Failed to ..." after the server applied the change,
  and a body without Success threw "Error message cannot be null". Clubs,
  announcements, keepsakes, comments/read, subscriptions, purchase offers
  and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
  device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
  GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
  Route inventory and coverage test extended to match.

README now describes the 2023 branch.
This commit is contained in:
Alexa
2026-10-15 14:08:11 +03:00
parent 36e64e99ba
commit 6ee1d6f9b3
45 changed files with 6666 additions and 448 deletions
@@ -101,7 +101,9 @@ public class AuthController(
[FromForm] int platform = 0,
[FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null)
[FromForm] long? account_id = null,
[FromForm] string? device_code = null,
[FromForm] string? login_token = null)
{
// The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge.
@@ -139,6 +141,55 @@ public class AuthController(
return TokenOk(acc1, refr1);
}
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
// The client keeps polling on HTTP 400 with error authorization_pending
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
// error ends the attempt. This grant used to fall through to the
// device-id fallback, which signed the polling device straight in as
// whatever account its device id mapped to — no approval involved.
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
{
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
switch (status)
{
case DeviceAuthorizations.RedeemStatus.Pending:
return BadRequest(new { error = "authorization_pending" });
case DeviceAuthorizations.RedeemStatus.Expired:
return BadRequest(new { error = "expired_token" });
case DeviceAuthorizations.RedeemStatus.Invalid:
return BadRequest(new { error = "invalid_grant" });
}
var approver = await playerService.GetByIdAsync(approverId);
if (approver is null) return BadRequest(new { error = "invalid_grant" });
await TryAwardLoginXpAsync(approver.Id);
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
return TokenOk(accD, refrD);
}
// One-shot token from account/me/createlogintoken, redeemed with the
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
// account_id, login_token). Also used to fall through to the device-id
// fallback, ignoring both account_id and the token.
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
{
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
var prefix = login_token.Trim() + "|";
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
s.PlayerId == tokenAccountId
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
&& s.Value.StartsWith(prefix));
if (row is null
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|| tokenExpiry < DateTime.UtcNow)
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
return TokenOk(accL, refrL);
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(refresh_token))
@@ -364,26 +415,20 @@ public class AuthController(
[HttpGet("/connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization()
{
var deviceCode = Guid.NewGuid().ToString("N");
var userCode = deviceCode[..8].ToUpperInvariant();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = RelationshipQueries.SystemAccountId,
Key = $"deviceauthorization:{deviceCode}",
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
});
await db.SaveChangesAsync();
var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
return Ok(new
{
device_code = deviceCode,
user_code = userCode,
device_code = created.DeviceCode,
user_code = created.UserCode,
verification_uri = $"https://{domain.Sub("auth")}/device",
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
expires_in = 900,
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
expires_in = created.ExpiresInSeconds,
interval = 5,
});
}
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
{
Platform = platform,