Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class CurationDeskPayloadTests
CurationDeskWrites.RosterFeed, CurationDeskWrites.Tick, CurationDeskWrites.Mark,
CurationDeskWrites.MarkClear, CurationDeskWrites.Marks, CurationDeskWrites.Cursor,
CurationDeskWrites.RecommendMeta, CurationDeskWrites.RecommendationDismiss, CurationDeskWrites.Ingest,
CurationDeskWrites.RosterList, CurationDeskWrites.RosterSet, CurationDeskWrites.RosterRetire,
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
};

private const string IngestBody =
Expand Down Expand Up @@ -54,6 +55,10 @@ private static string ValidBodyFor(CurationDeskWrites.Route route)
return "{" + forged + "\"author\":\"bob\",\"permlink\":\"p\",\"action\":\"restore\"}";
if (ReferenceEquals(route, CurationDeskWrites.Ingest))
return "{" + forged + IngestBody + "}";
if (ReferenceEquals(route, CurationDeskWrites.RosterSet))
return "{" + forged + "\"curator\":\"bob\",\"role\":\"curator\"}";
if (ReferenceEquals(route, CurationDeskWrites.RosterRetire))
return "{" + forged + "\"curator\":\"bob\"}";
return "{" + forged + "\"limit\":5}";
}

Expand Down Expand Up @@ -538,6 +543,101 @@ public void ANameOutsideTheGrammarHasNoRecommenderPath(string username)
Assert.Null(PrivateApi.CurationDeskRecommenderPath(username));
}

// ---- roster writes -------------------------------------------------------

[Fact]
public void ARosterSetForwardsOnlyTheCuratorFieldsAndNeverTheCallersIdentity()
{
var payload = Ok(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"mod\",\"rules\":{\"trail\":false,\"min_weight\":1090},"
+ "\"note\":\"mod only\",\"active\":true,\"added_by\":\"someone\",\"removed_at\":null}");
Assert.Equal(new[] { "username", "curator", "role", "rules", "note" }, payload.Select(kv => kv.Key).ToArray());
Assert.Equal("alice", payload["username"]!.GetValue<string>());
Assert.Equal("bob", payload["curator"]!.GetValue<string>());
// The rules object travels as sent: the backend stores it whole, and a key
// dropped here would silently change what the admin asked for.
var rules = (JsonObject)payload["rules"]!;
Assert.False(rules["trail"]!.GetValue<bool>());
Assert.Equal(1090, rules["min_weight"]!.GetValue<int>());
}

[Theory]
[InlineData("{\"role\":\"curator\"}", "curator required")]
[InlineData("{\"curator\":\"Bob\",\"role\":\"curator\"}", "curator required")]
[InlineData("{\"curator\":\"bob\\n\",\"role\":\"curator\"}", "curator required")]
[InlineData("{\"curator\":\"bob\"}", "invalid role")]
[InlineData("{\"curator\":\"bob\",\"role\":\"owner\"}", "invalid role")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":\"trail\"}", "rules must be an object")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"weight\":1}}", "unknown rule: weight")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"trail\":\"yes\"}}", "trail must be true or false")]
public void AMalformedRosterSetIsRefusedRatherThanTrimmed(string json, string expected)
{
Assert.Equal(expected, Rejected(CurationDeskWrites.RosterSet, json));
}

[Theory]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"min_weight\":10001}}")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"min_weight\":-1}}")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"min_weight\":true}}")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"max_weight\":\"2000\"}}")]
[InlineData("{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":{\"waves_only_below\":1.5}}")]
public void AWeightRuleOutsideTheVoteRangeIsRefused(string json)
{
Assert.Contains("vote weight between 0 and 10000", Rejected(CurationDeskWrites.RosterSet, json));
}

[Fact]
public void ACuratorNoteLongerThanTheColumnIsRefused()
{
var note = new string('x', 201);
Assert.Equal("invalid note", Rejected(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"curator\",\"note\":\"" + note + "\"}"));
Assert.True(Ok(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"curator\",\"note\":\"" + note[..200] + "\"}").ContainsKey("note"));
}

[Fact]
public void ANoteIsMeasuredTheWayTheColumnMeasuresIt()
{
// varchar(200) counts characters and Python's len() counts code points, but
// string.Length counts UTF-16 code units, so 200 emoji measure 400 and a note the
// column would have accepted was refused here.
var emoji = string.Concat(Enumerable.Repeat("\U0001F600", 200));
Assert.Equal(400, emoji.Length);
Assert.True(Ok(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"curator\",\"note\":\"" + emoji + "\"}").ContainsKey("note"));
Assert.Equal("invalid note", Rejected(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"curator\",\"note\":\"" + emoji + "\U0001F600\"}"));
}

[Fact]
public void APresentButNullRulesIsRefusedRatherThanForwarded()
{
// CopyIfPresent forwards a null through the allowlist, so "rules": null would
// travel upstream while the fence claimed every rules value is an object.
Assert.Equal("rules must be an object", Rejected(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"curator\",\"rules\":null}"));
// absent is still absent: that is how an admin clears every rule
Assert.False(Ok(CurationDeskWrites.RosterSet,
"{\"curator\":\"bob\",\"role\":\"curator\"}").ContainsKey("rules"));
}

[Fact]
public void ARetireCarriesNothingButTheCurator()
{
var payload = Ok(CurationDeskWrites.RosterRetire, "{\"curator\":\"bob\",\"role\":\"admin\",\"force\":true}");
Assert.Equal(new[] { "username", "curator" }, payload.Select(kv => kv.Key).ToArray());
Assert.Equal("curator required", Rejected(CurationDeskWrites.RosterRetire, "{}"));
Assert.Equal("curator required", Rejected(CurationDeskWrites.RosterRetire, "{\"curator\":\"..\"}"));
}

[Fact]
public void TheRosterListCarriesNothingTheCallerSent()
{
var payload = Ok(CurationDeskWrites.RosterList, "{\"limit\":5,\"role\":\"admin\",\"include_retired\":true}");
Assert.Equal(new[] { "username" }, payload.Select(kv => kv.Key).ToArray());
}

[Fact]
public void TheRecommenderNameLengthBoundIsEnforced()
{
Expand Down
4 changes: 4 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,5 +258,9 @@ public static TestClock UseTestClock()
yield return ("recommendation-dismiss", PrivateApi.CurationDeskRecommendationDismiss, "{" + code + ",\"author\":\"bob\",\"permlink\":\"p\",\"action\":\"dismiss\"}");
yield return ("ingest", PrivateApi.CurationDeskIngest,
"{" + code + ",\"v\":1,\"type\":\"flag\",\"id\":\"flag:bob/p:hivewatchers\",\"ts\":\"2026-09-05T10:00:00Z\",\"attempts\":0,\"payload\":{\"author\":\"bob\",\"permlink\":\"p\",\"weight\":-10000}}");
yield return ("roster-list", PrivateApi.CurationDeskRosterList, "{" + code + "}");
yield return ("roster-set", PrivateApi.CurationDeskRosterSet,
"{" + code + ",\"curator\":\"bob\",\"role\":\"curator\"}");
yield return ("roster-retire", PrivateApi.CurationDeskRosterRetire, "{" + code + ",\"curator\":\"bob\"}");
}
}
89 changes: 89 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,18 @@ public static Task CurationDeskMarks(HttpContext ctx) =>
public static Task CurationDeskCursor(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.Cursor);

// POST /private-api/curation-desk/roster-list
public static Task CurationDeskRosterList(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RosterList);

// POST /private-api/curation-desk/roster-set
public static Task CurationDeskRosterSet(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RosterSet);

// POST /private-api/curation-desk/roster-retire
public static Task CurationDeskRosterRetire(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RosterRetire);

// POST /private-api/curation-desk/recommend-meta
public static Task CurationDeskRecommendMeta(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RecommendMeta);
Expand Down Expand Up @@ -749,6 +761,13 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien
public static readonly IReadOnlySet<string> DismissActions = new HashSet<string> { "dismiss", "restore" };
public static readonly IReadOnlySet<string> UaClasses = new HashSet<string> { "web", "mobile" };
public static readonly IReadOnlySet<string> RosterSorts = new HashSet<string> { "queue", "newest", "unique", "random" };
/// <summary>The roles the backend's CHECK constraint accepts (spec 5.7).</summary>
public static readonly IReadOnlySet<string> Roles = new HashSet<string> { "admin", "mod", "curator", "trial" };
/// <summary>The per-curator trailing rules, as Hive vote weights (100 = 1%).</summary>
public static readonly string[] RuleWeightKeys = { "min_weight", "max_weight", "waves_only_below" };
public const int MaxVoteWeight = 10000;
/// <summary>The backend keeps a curator note in a varchar(200).</summary>
public const int MaxCuratorNoteLength = 200;
/// <summary>The four event types erobot pushes (spec 7.2).</summary>
public static readonly IReadOnlySet<string> IngestTypes = new HashSet<string> { "post", "vote", "curator_vote", "flag" };
/// <summary>The backend keeps the event id in a varchar(200).</summary>
Expand Down Expand Up @@ -800,6 +819,20 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien

public static readonly Route Marks = new("curation/desk/marks/list", new[] { "state", "cursor", "limit" });

/// <summary>
/// The roster write routes, all three admin-only upstream. They are POSTs rather
/// than an extension of the cached GET because the private view carries notes and
/// retired rows, and because a write must never be edge-cacheable. `rules` is the
/// one object here: unlike a read filter, an unknown key in it is refused rather
/// than dropped, so an admin is never told a rule was saved when it was discarded.
/// </summary>
public static readonly Route RosterList = new("curation/desk/roster/list", Array.Empty<string>());

public static readonly Route RosterSet = new("curation/desk/roster/set",
new[] { "curator", "role", "rules", "note" });

public static readonly Route RosterRetire = new("curation/desk/roster/retire", new[] { "curator" });

public static readonly Route Cursor = new("curation/desk/cursors", new[] { "post_id", "action", "reason" });

public static readonly Route RecommendMeta = new("curation/desk/recommendations/meta",
Expand Down Expand Up @@ -1052,6 +1085,62 @@ private static void Truncate(JsonObject payload, string key, int max)
{
return RequireAuthorPermlink(body) ?? RequireOneOf(body, "action", DismissActions);
}
if (ReferenceEquals(route, RosterSet))
{
if (body.Str("curator") is not { } curator || !HiveNames.IsAccountName(curator))
{
return "curator required";
}
var roleError = RequireOneOf(body, "role", Roles);
if (roleError != null) return roleError;
// varchar(200) counts CHARACTERS and Python's len() counts code points, but
// string.Length counts UTF-16 code units: 200 emoji measure 400 here and would
// be refused by the fence though the column accepts them. Count runes instead.
if (body.TryGetPropertyValue("note", out var note) && note is not null
&& (body.Str("note") is not { } text
|| text.EnumerateRunes().Count() > MaxCuratorNoteLength))
{
return "invalid note";
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
// A PRESENT `rules` must be an object, null included. `CopyIfPresent` forwards
// a null through the allowlist, and the fence should say what the contract says.
if (body.TryGetPropertyValue("rules", out var rules))
{
if (rules is not JsonObject ruleObject)
{
return "rules must be an object";
}
foreach (var rule in ruleObject)
{
if (rule.Key == "trail")
{
if (rule.Value?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False))
{
return "trail must be true or false";
}
continue;
}
if (Array.IndexOf(RuleWeightKeys, rule.Key) < 0)
{
return $"unknown rule: {rule.Key}";
}
if (rule.Value is not JsonValue weightValue
|| weightValue.GetValueKind() is not JsonValueKind.Number
|| !weightValue.TryGetValue<int>(out var weight)
|| weight < 0 || weight > MaxVoteWeight)
{
return $"{rule.Key} must be a vote weight between 0 and {MaxVoteWeight}";
}
}
}
return null;
}
if (ReferenceEquals(route, RosterRetire))
{
return body.Str("curator") is { } name && HiveNames.IsAccountName(name)
? null
: "curator required";
}
if (ReferenceEquals(route, Ingest))
{
// The envelope shape the backend accepts (spec 7.2): anything else is a
Expand Down
3 changes: 3 additions & 0 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/curation-desk/mark-clear", PrivateApi.CurationDeskMarkClear);
app.MapPost("/private-api/curation-desk/marks", PrivateApi.CurationDeskMarks);
app.MapPost("/private-api/curation-desk/cursor", PrivateApi.CurationDeskCursor);
app.MapPost("/private-api/curation-desk/roster-list", PrivateApi.CurationDeskRosterList);
app.MapPost("/private-api/curation-desk/roster-set", PrivateApi.CurationDeskRosterSet);
app.MapPost("/private-api/curation-desk/roster-retire", PrivateApi.CurationDeskRosterRetire);
app.MapPost("/private-api/curation-desk/recommend-meta", PrivateApi.CurationDeskRecommendMeta);
app.MapPost("/private-api/curation-desk/recommendation-dismiss", PrivateApi.CurationDeskRecommendationDismiss);
app.MapPost("/private-api/curation-desk/ingest", PrivateApi.CurationDeskIngest);
Expand Down
Loading