diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index 498ce2cc..f03f01f2 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -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) @@ -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()); + Assert.Equal(1, payload["v"]!.GetValue()); + Assert.Equal("post", payload["type"]!.GetValue()); + Assert.Equal("post:bob/p", payload["id"]!.GetValue()); + Assert.Equal("2026-09-05T10:00:00Z", payload["ts"]!.GetValue()); + // The nested object travels as it is: the backend range-checks its fields. + Assert.Equal("bob", payload["payload"]!["author"]!.GetValue()); + Assert.True(payload["payload"]!["flags"]!["spaminator"]!.GetValue()); + 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() { diff --git a/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs b/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs index f324e18d..ccd0b37e 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs @@ -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}}"); } } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index bf930925..717b896b 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -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); + // POST /private-api/curation-desk/recommendation-dismiss public static Task CurationDeskRecommendationDismiss(HttpContext ctx) => ServeDeskWrite(ctx, CurationDeskWrites.RecommendationDismiss); @@ -741,6 +749,10 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien public static readonly IReadOnlySet DismissActions = new HashSet { "dismiss", "restore" }; public static readonly IReadOnlySet UaClasses = new HashSet { "web", "mobile" }; public static readonly IReadOnlySet RosterSorts = new HashSet { "queue", "newest", "unique", "random" }; + /// The four event types erobot pushes (spec 7.2). + public static readonly IReadOnlySet IngestTypes = new HashSet { "post", "vote", "curator_vote", "flag" }; + /// The backend keeps the event id in a varchar(200). + public const int MaxIngestIdLength = 200; /// /// Views the roster feed takes: the public ones plus `excluded`, which is @@ -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" }); + /// + /// 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. + /// + public static readonly Route Ingest = new("curation/desk/ingest", new[] { "v", "type", "id", "ts", "payload" }); + /// /// The upstream body: the validated username plus the route's whitelisted /// keys copied as the client sent them. `username` and `code` are never in @@ -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(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; } diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 3c76ec15..1c197950 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -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); diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index dc44e18e..81846b5d 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -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") ]