feat: implement the client routes that had no handler

Sixteen agents, one controller each, over every route the coverage test
reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%),
up from 90.8%.

Each handler was written against binary evidence rather than the audit's prose:
verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from
the issuing method's return type, request shape from whether the client uses
form fields, a raw JsonUtility body, or query params. Agents were told to skip
rather than stub, and several did.

New surface worth noting:

- Link host: actionlink create/get/consume and datalink get/put, previously
  unrouted entirely.
- Club roster reads: members, banned, requests, requests/search, members/search,
  each with the client's real sort/paging params and the wire row shape its
  reader expects (the banned row has no MembershipType field, so it isn't sent).
- Home clubhouse set/clear, club disband (soft, via the existing State column),
  and DELETE mainimage as its own action rather than a verb alias — the client
  parses the details envelope from it, so it is not status-only.
- Club chat threads, comment CRUD, keepsake collect/delete, progression-event
  xp boosts, image report and cheered/bulk, iOS StoreKit product reporting.

The six routes still missing all need storage that does not exist yet
(per-role club permissions, club additional-image slots, MinLevel,
ClubChatEnabled). Those are left for a single schema change rather than faked.

Also excludes DELETE /account/me from the endpoint contract probe. That route is
now a real, correctly-guarded account purge, and the probe walks every route
with one test player — so calling it destroyed the probe's own session and
turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
Alexa
2026-07-28 09:11:26 +03:00
parent 90e069ee4b
commit 4b5086cdf1
21 changed files with 4474 additions and 370 deletions
@@ -1,3 +1,5 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -240,6 +242,318 @@ public class PhotosController(
return Ok(new { deleted = id });
}
// ── 2023 home feed (Discovery host) ───────────────────────────────
/// <summary>The only <c>FeedItemType</c> (client enum
/// <c>DAAEHNDDCCH</c>) the 2023 feed list can render: an image.
/// Two independent confirmations in the binary — the list spawner
/// builds one <c>RRUI.Data.ImageFeedItemModel</c> per returned item
/// (<c>Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294</c>
/// "044 Call ImageFeedItemModel.Set"), and the share deep-link
/// handler maps the path segment "image" + an Int64 id onto an
/// injected feed item constructed with the enum literal 0
/// (<c>Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646</c>
/// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call
/// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on
/// DorkNet is <see cref="PhotoEntity.Id"/> — the same id space
/// <c>api/images/v5/bulk</c> resolves.</summary>
private const int FeedItemTypeImage = 0;
/// <summary>Ranking revision stamped on the envelope and on every
/// item. The client never interprets it: it copies the Int16 off the
/// response into the list model and replays it in the impression
/// telemetry it later posts back (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c>
/// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then
/// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering
/// below changes so the telemetry can tell the cohorts apart.</summary>
private const short FeedAlgoVersion = 1;
/// <summary>POST /feed/query — the 2023 client's personalised home
/// feed. Host is Discovery (host enum 19) and the verb is POST:
/// <c>RecNet.Runtime/KGLCPCEGCFF.txt:166-178</c> loads
/// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the
/// shared BNDIAONDFFF request ctor. The body is a RAW JSON document
/// (the issuing method takes an already-serialised
/// <c>System.String</c> and hands it to BNDIAONDFFF.FJLLPHFOOJJ),
/// NOT form fields — so it is read straight off Request.Body.
///
/// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in
/// PascalCase/camelCase/lowercase): Take:Int32,
/// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType,
/// FeedItemId}], FeedInstanceId:String?.
///
/// Response is the generic result envelope AECMPGPHAII&lt;T&gt;
/// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping
/// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext,
/// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything
/// else — including a 404 — rejects the promise and the home tab
/// renders its failure state.
///
/// FeedContext is the continuation cursor, not a display string: the
/// RefreshPage continuation reads field +32 (FeedContext) as the
/// third element of its (count, items, nextToken) tuple and feeds it
/// back as the next request's ContinuationToken
/// (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c> "062 Move r9,
/// [rdi+32]"). An empty FeedContext therefore ends pagination.
///
/// Anonymous-safe like the other feed reads; when the caller is
/// authenticated the ordering is personalised (friends/subscriptions
/// first) and blocked/ignored uploaders are filtered out.</summary>
[HttpPost("/feed/query")]
[AllowAnonymous]
public async Task<ActionResult> FeedQuery()
{
var take = 20;
string? continuationToken = null;
string? requestedInstanceId = null;
var requestedInjected = new List<long>();
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
var root = doc.RootElement;
if (TryFeedProp(root, "Take", out var takeEl) &&
takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t))
take = t;
if (TryFeedProp(root, "ContinuationToken", out var ctEl) &&
ctEl.ValueKind == JsonValueKind.String)
continuationToken = ctEl.GetString();
if (TryFeedProp(root, "FeedInstanceId", out var fiEl) &&
fiEl.ValueKind == JsonValueKind.String)
requestedInstanceId = fiEl.GetString();
if (TryFeedProp(root, "InjectedFeedItems", out var injEl) &&
injEl.ValueKind == JsonValueKind.Array)
{
foreach (var entry in injEl.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object) continue;
// Non-image injected items can't be rendered by the
// 2023 list, so echoing them back would produce a
// blank card. Drop them.
var type = TryFeedProp(entry, "FeedItemType", out var tyEl) &&
tyEl.ValueKind == JsonValueKind.Number &&
tyEl.TryGetInt32(out var ty)
? ty : FeedItemTypeImage;
if (type != FeedItemTypeImage) continue;
if (TryFeedProp(entry, "FeedItemId", out var idEl) &&
idEl.ValueKind == JsonValueKind.Number &&
idEl.TryGetInt64(out var id) && id > 0)
requestedInjected.Add(id);
}
}
}
}
catch (JsonException)
{
// Unparseable/empty body — treat as a default first-page pull
// rather than 400, so a malformed retry still shows content.
}
take = Math.Clamp(take, 1, 50);
var me = this.CurrentPlayerId();
// Uploaders the caller can't see: anyone they blocked or ignored,
// plus anyone who blocked them.
var hidden = new List<long>();
var affinity = new List<long>();
if (me is long meId)
{
hidden = await db.Relationships
.Where(r => (r.RequesterId == meId &&
(r.Status == RelationshipStatus.Blocked || r.Ignored)) ||
(r.TargetId == meId && r.Status == RelationshipStatus.Blocked))
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
.Distinct()
.ToListAsync();
var friends = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Friend &&
(r.RequesterId == meId || r.TargetId == meId))
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
.ToListAsync();
var follows = await db.Subscriptions
.Where(s => s.SubscriberPlayerId == meId)
.Select(s => s.TargetPlayerId)
.ToListAsync();
affinity = friends.Concat(follows)
.Where(id => id > 0 && !hidden.Contains(id))
.Distinct()
.ToList();
}
var resumed = TryDecodeFeedCursor(continuationToken,
out var phase, out var cursorTicks, out var cursorId, out var carriedInjected);
// Injected items are pinned to the head of the FIRST page only, in
// the order the client asked for them (they come from a share
// deep-link the player just followed). Their ids ride along in the
// cursor so later pages don't repeat them organically.
var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray();
var itemIds = new List<long>();
if (!resumed && injectedIds.Length > 0)
{
var pinned = await VisibleFeedPhotos(hidden)
.Where(p => injectedIds.Contains(p.Id))
.Select(p => p.Id)
.ToListAsync();
itemIds.AddRange(injectedIds.Where(pinned.Contains));
}
DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks
? new DateTime(cursorTicks)
: (DateTime?)null;
var cutoffId = resumed ? cursorId : 0L;
if (phase < 0) phase = 0;
// Phase 0 = photos from people the caller follows or is friends
// with; phase 1 = everyone else. Both are newest-first, and the
// cursor is (CreatedAt, Id) so a photo posted mid-scroll can never
// shift a row onto a page the client already consumed.
while (itemIds.Count < take && phase <= 1)
{
if (phase == 0 && affinity.Count == 0)
{
phase = 1;
cutoffAt = null;
cutoffId = 0;
continue;
}
var need = take - itemIds.Count;
var q = VisibleFeedPhotos(hidden);
if (injectedIds.Length > 0)
q = q.Where(p => !injectedIds.Contains(p.Id));
q = phase == 0
? q.Where(p => affinity.Contains(p.UploaderPlayerId))
: q.Where(p => !affinity.Contains(p.UploaderPlayerId));
if (cutoffAt is DateTime at)
{
var atId = cutoffId;
q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId));
}
var batch = await q
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(need)
.Select(p => new { p.Id, p.CreatedAt })
.ToListAsync();
itemIds.AddRange(batch.Select(b => b.Id));
if (batch.Count < need)
{
phase++;
cutoffAt = null;
cutoffId = 0;
}
else
{
cutoffAt = batch[^1].CreatedAt;
cutoffId = batch[^1].Id;
}
}
// Empty FeedContext = end of the feed; the client stops paging.
var nextContext = phase > 1
? string.Empty
: EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds);
return Ok(new
{
Success = true,
Error = (string?)null,
Value = new
{
Items = itemIds.Select(id => new
{
FeedItemType = FeedItemTypeImage,
FeedItemId = id,
FeedAlgorithmVersion = FeedAlgoVersion,
}).ToList(),
// The client sends the instance id it generated for this
// scroll session and stores whatever comes back; echoing it
// keeps its impression telemetry stitched to one session.
FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId)
? Guid.NewGuid().ToString("N")
: requestedInstanceId,
FeedContext = nextContext,
FeedAlgorithmVersion = FeedAlgoVersion,
},
});
}
/// <summary>Photos eligible for the public home feed: live, public,
/// and not from an uploader the caller blocked/ignored.</summary>
private IQueryable<PhotoEntity> VisibleFeedPhotos(List<long> hidden)
{
var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null);
if (hidden.Count > 0)
q = q.Where(p => !hidden.Contains(p.UploaderPlayerId));
return q;
}
/// <summary>Case-insensitive property lookup — the 2023 generated
/// writers emit PascalCase but their readers register all three
/// casings, so tolerate whatever the client sends.</summary>
private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>Opaque base64url cursor handed to the client as
/// FeedContext. Carries the phase, the (CreatedAt, Id) keyset
/// position within that phase, and the pinned injected ids so they
/// stay suppressed on later pages.</summary>
private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList<long> injected)
{
var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static bool TryDecodeFeedCursor(string? token, out int phase,
out long ticks, out long lastId, out long[] injected)
{
phase = 0;
ticks = 0;
lastId = 0;
injected = Array.Empty<long>();
if (string.IsNullOrWhiteSpace(token)) return false;
try
{
var b64 = token.Replace('-', '+').Replace('_', '/');
b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '=');
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|');
if (parts.Length != 5 || parts[0] != "1") return false;
if (!int.TryParse(parts[1], out phase)) return false;
if (!long.TryParse(parts[2], out ticks)) return false;
if (!long.TryParse(parts[3], out lastId)) return false;
injected = parts[4]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.ToArray();
return true;
}
catch (FormatException)
{
// Garbage/stale cursor — restart the feed from the top instead
// of failing the whole pull.
return false;
}
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.