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
53 changes: 52 additions & 1 deletion dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ public class CurationDeskPayloadTests
{
CurationDeskWrites.RosterFeed, CurationDeskWrites.Tick, CurationDeskWrites.Mark,
CurationDeskWrites.MarkClear, CurationDeskWrites.Marks, CurationDeskWrites.Cursor,
CurationDeskWrites.RecommendMeta, CurationDeskWrites.RecommendationDismiss,
CurationDeskWrites.RecommendMeta, CurationDeskWrites.RecommendationDismiss, CurationDeskWrites.Ingest,
};

private const string IngestBody =
"\"v\":1,\"type\":\"post\",\"id\":\"post:bob/p\",\"ts\":\"2026-09-05T10:00:00Z\",\"attempts\":3,"
+ "\"payload\":{\"author\":\"bob\",\"permlink\":\"p\",\"rep\":71,\"flags\":{\"spaminator\":true}}";

private static JsonObject Body(string json) => (JsonObject)JsonNode.Parse(json)!;

private static JsonObject Ok(CurationDeskWrites.Route route, string json)
Expand Down Expand Up @@ -48,9 +52,56 @@ private static string ValidBodyFor(CurationDeskWrites.Route route)
return "{" + forged + "\"post_id\":7,\"action\":\"advance\"}";
if (ReferenceEquals(route, CurationDeskWrites.RecommendationDismiss))
return "{" + forged + "\"author\":\"bob\",\"permlink\":\"p\",\"action\":\"restore\"}";
if (ReferenceEquals(route, CurationDeskWrites.Ingest))
return "{" + forged + IngestBody + "}";
return "{" + forged + "\"limit\":5}";
}

// ---- ingest envelope -----------------------------------------------------

[Fact]
public void TheIngestEnvelopeIsForwardedWithoutTheSendersRetryCounter()
{
var payload = Ok(CurationDeskWrites.Ingest, "{" + IngestBody + "}");
Assert.Equal("alice", payload["username"]!.GetValue<string>());
Assert.Equal(1, payload["v"]!.GetValue<int>());
Assert.Equal("post", payload["type"]!.GetValue<string>());
Assert.Equal("post:bob/p", payload["id"]!.GetValue<string>());
Assert.Equal("2026-09-05T10:00:00Z", payload["ts"]!.GetValue<string>());
// The nested object travels as it is: the backend range-checks its fields.
Assert.Equal("bob", payload["payload"]!["author"]!.GetValue<string>());
Assert.True(payload["payload"]!["flags"]!["spaminator"]!.GetValue<bool>());
Assert.False(payload.ContainsKey("attempts"));
Assert.False(payload.ContainsKey("code"));
}

[Theory]
[InlineData("{\"v\":2,\"type\":\"post\",\"id\":\"post:bob/p\",\"payload\":{}}", "unsupported envelope version")]
[InlineData("{\"v\":\"1\",\"type\":\"post\",\"id\":\"post:bob/p\",\"payload\":{}}", "unsupported envelope version")]
[InlineData("{\"type\":\"post\",\"id\":\"post:bob/p\",\"payload\":{}}", "unsupported envelope version")]
[InlineData("{\"v\":1,\"type\":\"like\",\"id\":\"post:bob/p\",\"payload\":{}}", "invalid type")]
[InlineData("{\"v\":1,\"id\":\"post:bob/p\",\"payload\":{}}", "invalid type")]
[InlineData("{\"v\":1,\"type\":\"post\",\"payload\":{}}", "id required")]
[InlineData("{\"v\":1,\"type\":\"post\",\"id\":\"\",\"payload\":{}}", "id required")]
[InlineData("{\"v\":1,\"type\":\"post\",\"id\":7,\"payload\":{}}", "id required")]
[InlineData("{\"v\":1,\"type\":\"post\",\"id\":\"post:bob/p\",\"ts\":5,\"payload\":{}}", "invalid ts")]
[InlineData("{\"v\":1,\"type\":\"post\",\"id\":\"post:bob/p\"}", "payload required")]
[InlineData("{\"v\":1,\"type\":\"post\",\"id\":\"post:bob/p\",\"payload\":\"x\"}", "payload required")]
[InlineData("{\"v\":1,\"type\":\"post\",\"id\":\"post:bob/p\",\"payload\":[1]}", "payload required")]
public void AnEnvelopeTheBackendWouldRefuseIsRefusedHere(string body, string error)
{
Assert.Equal(error, Rejected(CurationDeskWrites.Ingest, body));
}

[Fact]
public void TheEventIdLengthBoundIsTheBackendsColumn()
{
string With(int length) =>
"{\"v\":1,\"type\":\"vote\",\"id\":\"" + new string('a', length) + "\",\"payload\":{}}";
Ok(CurationDeskWrites.Ingest, With(CurationDeskWrites.MaxIngestIdLength));
Assert.Equal("id required", Rejected(CurationDeskWrites.Ingest, With(CurationDeskWrites.MaxIngestIdLength + 1)));
}

[Fact]
public void TheValidatedUsernameIsTheOnlyIdentityForwarded()
{
Expand Down
2 changes: 2 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,5 +256,7 @@ public static TestClock UseTestClock()
yield return ("cursor", PrivateApi.CurationDeskCursor, "{" + code + ",\"post_id\":42,\"action\":\"advance\"}");
yield return ("recommend-meta", PrivateApi.CurationDeskRecommendMeta, "{" + code + ",\"author\":\"bob\",\"permlink\":\"p\",\"ua_class\":\"web\"}");
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}}");
}
}
43 changes: 43 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,14 @@ public static Task CurationDeskCursor(HttpContext ctx) =>
public static Task CurationDeskRecommendMeta(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RecommendMeta);

// POST /private-api/curation-desk/ingest
// erobot's observations (post cards, trail votes, curator votes, flags), sent
// as the @ecency account with its own signed code: the same write pipeline as
// every other desk route, so the backend sees the validated username and
// decides whether that account may ingest.
public static Task CurationDeskIngest(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.Ingest);
Comment thread
feruzm marked this conversation as resolved.

// POST /private-api/curation-desk/recommendation-dismiss
public static Task CurationDeskRecommendationDismiss(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RecommendationDismiss);
Expand Down Expand Up @@ -741,6 +749,10 @@ 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 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>
public const int MaxIngestIdLength = 200;

/// <summary>
/// Views the roster feed takes: the public ones plus `excluded`, which is
Expand Down Expand Up @@ -780,6 +792,13 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien
public static readonly Route RecommendationDismiss = new("curation/desk/recommendations/dismiss",
new[] { "author", "permlink", "action" });

/// <summary>
/// erobot's event envelope. `attempts` is the sender's retry counter and stays
/// behind; `payload` is forwarded as the object it is, the backend range-checks
/// every field of it before storing anything.
/// </summary>
public static readonly Route Ingest = new("curation/desk/ingest", new[] { "v", "type", "id", "ts", "payload" });

/// <summary>
/// The upstream body: the validated username plus the route's whitelisted
/// keys copied as the client sent them. `username` and `code` are never in
Expand Down Expand Up @@ -973,6 +992,30 @@ private static void Truncate(JsonObject payload, string key, int max)
{
return RequireAuthorPermlink(body) ?? RequireOneOf(body, "action", DismissActions);
}
if (ReferenceEquals(route, Ingest))
{
// The envelope shape the backend accepts (spec 7.2): anything else is a
// 4xx there too, so refusing here saves the authenticated round trip.
if (body.Field("v") is not JsonValue version || !version.TryGetValue<int>(out var v) || v != 1)
{
return "unsupported envelope version";
}
var typeError = RequireOneOf(body, "type", IngestTypes);
if (typeError != null) return typeError;
if (body.Str("id") is not { Length: > 0 and <= MaxIngestIdLength })
{
return "id required";
}
if (body.ContainsKey("ts") && body.Str("ts") == null)
{
return "invalid ts";
}
if (body.Field("payload") is not JsonObject)
{
return "payload required";
}
return null;
}
return null;
}

Expand Down
1 change: 1 addition & 0 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/curation-desk/cursor", PrivateApi.CurationDeskCursor);
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);

// ---- SSR RPC cache (internal, header-gated; see SsrRpc.cs) ----
app.MapPost("/private-api/ssr/rpc", SsrRpc.Rpc);
Expand Down
2 changes: 1 addition & 1 deletion dotnet/parity/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ def norm_body(text):
] + [
f"/private-api/curation-desk/{route}::{case}"
for route in ("roster-feed", "tick", "mark", "mark-clear", "marks", "cursor",
"recommend-meta", "recommendation-dismiss")
"recommend-meta", "recommendation-dismiss", "ingest")
for case in ("min", "pop", "badcode")
]

Expand Down
Loading