diff --git a/dotnet/EcencyApi.Tests/CachePolicyTests.cs b/dotnet/EcencyApi.Tests/CachePolicyTests.cs index d1e5c392..490907de 100644 --- a/dotnet/EcencyApi.Tests/CachePolicyTests.cs +++ b/dotnet/EcencyApi.Tests/CachePolicyTests.cs @@ -24,6 +24,7 @@ public static TheoryData AllPolicies() => { "desk-roster", CachePolicy.CurationDeskRoster }, { "desk-recommendations", CachePolicy.CurationDeskRecommendations }, { "desk-post", CachePolicy.CurationDeskPost }, + { "desk-recommender", CachePolicy.CurationDeskRecommender }, }; public static TheoryData DeskPolicies() => @@ -34,6 +35,7 @@ public static TheoryData DeskPolicies() => { "desk-roster", CachePolicy.CurationDeskRoster, 600 }, { "desk-recommendations", CachePolicy.CurationDeskRecommendations, 30 }, { "desk-post", CachePolicy.CurationDeskPost, 15 }, + { "desk-recommender", CachePolicy.CurationDeskRecommender, 60 }, }; [Theory] diff --git a/dotnet/EcencyApi.Tests/CurationDeskAuthTests.cs b/dotnet/EcencyApi.Tests/CurationDeskAuthTests.cs index 3d3bf188..4d64994e 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskAuthTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskAuthTests.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using EcencyApi.Handlers; using EcencyApi.Infrastructure; +using Microsoft.AspNetCore.Http; using Xunit; using static EcencyApi.Tests.CurationDeskTestSupport; @@ -384,6 +385,7 @@ public void EachPolicySharedMaxAgeIsTheMemoTtl() Assert.Equal(600, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskRoster)); Assert.Equal(30, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskRecommendations)); Assert.Equal(15, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskPost)); + Assert.Equal(60, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskRecommender)); } // ---- the byte memo ------------------------------------------------------- @@ -730,4 +732,143 @@ private static readonly (string Author, string Permlink)[] MalformedPostPaths = { ("..", "p"), ("good-karma", "a/b"), ("good-karma", "p?x=1"), ("x", "p"), }; + + // ---- the recommender scorecard ------------------------------------------- + + private static DefaultHttpContext RecommenderRequest(string username, string query = "") => + Get("/private-api/curation-desk/recommender/" + username, query, new[] { ("username", username) }); + + private const string Scorecard = + "{\"username\":\"good-karma\",\"window_days\":90,\"recommended\":12,\"curated\":9," + + "\"dismissed\":1,\"withdrawn\":0,\"precision\":0.75,\"trusted\":true,\"computed_at\":\"t\"}"; + + [Fact] + public async Task AScorecardIsPipedUnderTheTokenAndCachedForAMinute() + { + var upstream = Install(); + var clock = UseTestClock(); + upstream.Answer = _ => Task.FromResult(JsonResponse(200, Scorecard)); + + var fill = RecommenderRequest("good-karma"); + await PrivateApi.CurationDeskRecommender(fill); + await Start(fill); + + var call = Assert.Single(upstream.Calls); + Assert.Equal("curation/desk/recommenders/good-karma", call.Endpoint); + Assert.Equal(HttpMethod.Get, call.Method); + Assert.Equal(Token, call.Header(PrivateApi.DeskTokenHeader)); + Assert.Equal(200, fill.Response.StatusCode); + Assert.Equal(Scorecard, Body(fill)); + Assert.StartsWith("application/json", fill.Response.ContentType); + Assert.Equal("public, max-age=0, s-maxage=60", CacheControl(fill)); + Assert.Equal(CachePolicy.CurationDeskRecommender, CacheControl(fill)); + Assert.Null(Age(fill)); + + // 45 s into the minute the policy promises: the hit is offered only the + // rest of that window; its age is not advertised a second time. + clock.Advance(TimeSpan.FromSeconds(45)); + var hit = RecommenderRequest("good-karma"); + await PrivateApi.CurationDeskRecommender(hit); + await Start(hit); + Assert.Equal(200, hit.Response.StatusCode); + Assert.Equal(Scorecard, Body(hit)); + Assert.Equal("public, max-age=0, s-maxage=15", CacheControl(hit)); + Assert.Null(Age(hit)); + Assert.Single(upstream.Calls); + } + + [Fact] + public async Task TheScorecardIsKeyedByTheNameAloneAndTakesNoQueryParameters() + { + var upstream = Install(); + upstream.Answer = _ => Task.FromResult(JsonResponse(200, Scorecard)); + + await PrivateApi.CurationDeskRecommender(RecommenderRequest("good-karma")); + await PrivateApi.CurationDeskRecommender(RecommenderRequest("good-karma", "window_days=7&limit=50")); + + // One question, one upstream call and one memo entry: a query string + // cannot fork the key or reach the backend. + var call = Assert.Single(upstream.Calls); + Assert.Equal("curation/desk/recommenders/good-karma", call.Endpoint); + Assert.True(CurationDeskMemo.TryGetFresh("curation/desk/recommenders/good-karma", out _, out _, out _)); + Assert.Equal(1, CurationDeskMemo.Fresh.Count); + + // A different name is a different entry. + await PrivateApi.CurationDeskRecommender(RecommenderRequest("user.name")); + Assert.Equal(2, upstream.Calls.Count); + Assert.Equal("curation/desk/recommenders/user.name", upstream.Calls[1].Endpoint); + } + + [Fact] + public async Task AnInvalidRecommenderNameIs400BeforeAnyUpstreamCall() + { + var upstream = Install(); + foreach (var username in MalformedRecommenderNames) + { + var ctx = RecommenderRequest(username); + await PrivateApi.CurationDeskRecommender(ctx); + await Start(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("Invalid username", Body(ctx)); + Assert.Null(CacheControl(ctx)); + } + Assert.Empty(upstream.Calls); + } + + [Fact] + public async Task WithoutTheTokenAMalformedRecommenderNameIs503LikeEveryOtherRoute() + { + var upstream = Install(token: null); + + // The 503 is decided before the route value is read, so a dark desk does + // not single this route out by reporting on the name it was given. + foreach (var username in MalformedRecommenderNames) + { + var ctx = RecommenderRequest(username); + await PrivateApi.CurationDeskRecommender(ctx); + await Start(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Equal("curation desk not configured", Body(ctx)); + Assert.Null(CacheControl(ctx)); + } + + // Including a request that carries no route value at all. + var bare = Get("/private-api/curation-desk/recommender/good-karma"); + await PrivateApi.CurationDeskRecommender(bare); + await Start(bare); + Assert.Equal(503, bare.Response.StatusCode); + Assert.Equal("curation desk not configured", Body(bare)); + Assert.Empty(upstream.Calls); + } + + [Fact] + public async Task AScorecardGoesThroughTheSameFenceAsEveryOtherPublicBody() + { + var upstream = Install(); + upstream.Answer = _ => Task.FromResult(JsonResponse(200, + "{\"username\":\"good-karma\",\"precision\":0.75,\"trusted\":true,\"ip_hash\":\"ab\"," + + "\"key_id\":3,\"note\":\"secret\",\"computed_at\":\"t\"}")); + + var ctx = RecommenderRequest("good-karma"); + await PrivateApi.CurationDeskRecommender(ctx); + await Start(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var body = Body(ctx); + Assert.Contains("\"precision\":0.75", body); + Assert.Contains("\"computed_at\":\"t\"", body); + Assert.DoesNotContain("ip_hash", body); + Assert.DoesNotContain("key_id", body); + Assert.DoesNotContain("note", body); + + // The memo holds the stripped bytes, so a hit cannot leak them either. + var hit = RecommenderRequest("good-karma"); + await PrivateApi.CurationDeskRecommender(hit); + Assert.Equal(body, Body(hit)); + Assert.Single(upstream.Calls); + } + + private static readonly string[] MalformedRecommenderNames = + { + "..", "a%2Fb", "good-karma?x=1", "Good-Karma", new string('a', 17), + }; } diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index 7c718d12..498ce2cc 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -344,6 +344,16 @@ public void RealAuthorsAndPermlinksMapToTheUpstreamPathUnchanged() [InlineData("good-karma", "a_b")] [InlineData("", "p")] [InlineData("undefined-undefined", "p")] + // The right alphabet and length, but not a name: labels must be three or + // more characters, start with a letter and end with a letter or digit. + [InlineData("-ab", "p")] + [InlineData("abc-", "p")] + [InlineData("a..b", "p")] + [InlineData("ab.cdef", "p")] + [InlineData("...", "p")] + [InlineData(".abc", "p")] + [InlineData("abc.", "p")] + [InlineData("1abc", "p")] // `$` would match before a trailing newline; these anchor with \A and \z. [InlineData("good-karma\n", "p")] [InlineData("good-karma", "p\n")] @@ -360,4 +370,63 @@ public void ThePermlinkLengthBoundIsEnforced() Assert.NotNull(PrivateApi.CurationDeskPostPath(new string('a', 16), "p")); Assert.Null(PrivateApi.CurationDeskPostPath(new string('a', 17), "p")); } + + // ---- route 14 path ------------------------------------------------------- + + [Fact] + public void ARecommenderNameMapsToTheUpstreamPathUnchanged() + { + Assert.Equal("curation/desk/recommenders/good-karma", + PrivateApi.CurationDeskRecommenderPath("good-karma")); + Assert.Equal("curation/desk/recommenders/user.name", + PrivateApi.CurationDeskRecommenderPath("user.name")); + Assert.Equal("curation/desk/recommenders/a-b", + PrivateApi.CurationDeskRecommenderPath("a-b")); + Assert.Equal("curation/desk/recommenders/abc1.d-2e.fgh", + PrivateApi.CurationDeskRecommenderPath("abc1.d-2e.fgh")); + Assert.Equal("curation/desk/recommenders/" + new string('a', 16), + PrivateApi.CurationDeskRecommenderPath(new string('a', 16))); + } + + [Theory] + // Dot segments would resolve upward once the string becomes a Uri. + [InlineData("..")] + [InlineData(".")] + // Route values arrive percent-decoded, so a slash is a slash; and the + // still-encoded spelling is not a name character either. + [InlineData("a/b")] + [InlineData("a%2Fb")] + // A question mark or hash would truncate the path. + [InlineData("good-karma?x=1")] + [InlineData("good-karma#f")] + // Outside the Hive name grammar, on either side of the length bound. + [InlineData("")] + [InlineData("ab")] + [InlineData("Good-Karma")] + [InlineData("good_karma")] + // The right alphabet and length, but not a name: labels must be three or + // more characters, start with a letter and end with a letter or digit. + [InlineData("-ab")] + [InlineData("abc-")] + [InlineData("a..b")] + [InlineData("ab.cdef")] + [InlineData("...")] + [InlineData(".abc")] + [InlineData("abc.")] + [InlineData("1abc")] + [InlineData("abc.-def")] + [InlineData("abc.def-")] + // `$` would match before a trailing newline; this anchors with \A and \z. + [InlineData("good-karma\n")] + public void ANameOutsideTheGrammarHasNoRecommenderPath(string username) + { + Assert.Null(PrivateApi.CurationDeskRecommenderPath(username)); + } + + [Fact] + public void TheRecommenderNameLengthBoundIsEnforced() + { + Assert.NotNull(PrivateApi.CurationDeskRecommenderPath(new string('a', 3))); + Assert.Null(PrivateApi.CurationDeskRecommenderPath(new string('a', 17))); + } } diff --git a/dotnet/EcencyApi.Tests/CurationDeskPublicPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPublicPayloadTests.cs index 653eedbf..dcdb35ba 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPublicPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPublicPayloadTests.cs @@ -142,6 +142,6 @@ public async Task EveryPublicRouteServesAndMemoizesTheStrippedBody() AssertClean(JsonNode.Parse(Body(again))); } - Assert.Equal(5, upstream.Calls.Count); + Assert.Equal(PublicReads().Count(), upstream.Calls.Count); } } diff --git a/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs b/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs index 6a7da1a4..f324e18d 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs @@ -239,6 +239,9 @@ public static TestClock UseTestClock() yield return ("post", PrivateApi.CurationDeskPost, () => Get("/private-api/curation-desk/post/good-karma/hello-world", "", new[] { ("author", "good-karma"), ("permlink", "hello-world") }), CachePolicy.CurationDeskPost); + yield return ("recommender", PrivateApi.CurationDeskRecommender, + () => Get("/private-api/curation-desk/recommender/good-karma", "", + new[] { ("username", "good-karma") }), CachePolicy.CurationDeskRecommender); } /// Every signed write, as (handler, a body that passes validation). diff --git a/dotnet/EcencyApi.Tests/HiveNamesTests.cs b/dotnet/EcencyApi.Tests/HiveNamesTests.cs new file mode 100644 index 00000000..2f0d9eaa --- /dev/null +++ b/dotnet/EcencyApi.Tests/HiveNamesTests.cs @@ -0,0 +1,62 @@ +using EcencyApi.Infrastructure; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The account name grammar behind every desk path: the chain's +/// is_valid_account_name, label by label. +/// +public class HiveNamesTests +{ + [Theory] + [InlineData("abc")] + [InlineData("a-b")] + [InlineData("a1b")] + [InlineData("good-karma")] + [InlineData("user.name")] + [InlineData("abc1.d-2e.fgh")] + [InlineData("a--b")] + [InlineData("abcdefghijklmnop")] + public void EveryLabelOfAValidNameStartsWithALetterAndEndsWithALetterOrDigit(string name) + { + Assert.True(HiveNames.IsAccountName(name)); + } + + [Theory] + // Length bounds. + [InlineData("")] + [InlineData("ab")] + [InlineData("abcdefghijklmnopq")] + // Alphabet. + [InlineData("Abc")] + [InlineData("a_b")] + [InlineData("abc\n")] + [InlineData("ab c")] + // Label edges. + [InlineData("-ab")] + [InlineData("1ab")] + [InlineData("abc-")] + [InlineData("abc.-de")] + [InlineData("abc.def-")] + [InlineData("abc.1de")] + // Label length. + [InlineData("ab.cdef")] + [InlineData("abcd.ef")] + [InlineData("a..b")] + [InlineData("...")] + // Empty first or last label. + [InlineData(".abc")] + [InlineData("abc.")] + [InlineData("abc..def")] + public void AnythingElseIsNotAName(string name) + { + Assert.False(HiveNames.IsAccountName(name)); + } + + [Fact] + public void ANullNameIsNotAName() + { + Assert.False(HiveNames.IsAccountName(null)); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index 568171f8..bf930925 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -11,7 +11,7 @@ namespace EcencyApi.Handlers; /// /// Curation desk gateway: /private-api/curation-desk/* -> curation/desk/* upstream. /// -/// Five public reads and eight signed writes. This service does three things +/// Six public reads and eight signed writes. This service does three things /// for the desk that the generic pipe handlers do not: /// /// - every upstream call carries a shared secret header, reads included. The @@ -104,7 +104,7 @@ public static async Task CurationDeskPost(HttpContext ctx) // The unconfigured answer comes first, before this route looks at // anything the caller sent. A dark desk answers 503 on every route the // same way; answering 400 here instead would make this one route report - // on its own path grammar while the other four report nothing. + // on its own path grammar while the other five report nothing. if (DeskToken == null) { await ctx.SendText(503, DeskNotConfigured); @@ -122,10 +122,32 @@ public static async Task CurationDeskPost(HttpContext ctx) await ServeDeskRead(ctx, path, CachePolicy.CurationDeskPost); } - // \A and \z, not ^ and $: in .NET `$` also matches before a trailing - // newline, so "good-karma\n" would pass a `$`-anchored name check and - // travel into the upstream path. - private static readonly Regex DeskAuthorPattern = new(@"\A[a-z0-9.-]{3,16}\z", RegexOptions.Compiled); + // GET /private-api/curation-desk/recommender/{username} + public static async Task CurationDeskRecommender(HttpContext ctx) + { + // Same order as the post route: while the desk is dark every route + // answers 503, before this one looks at the name it was given. + if (DeskToken == null) + { + await ctx.SendText(503, DeskNotConfigured); + return; + } + + var username = ctx.Request.RouteValues["username"]?.ToString() ?? ""; + var path = CurationDeskRecommenderPath(username); + if (path == null) + { + await ctx.SendText(400, "Invalid username"); + return; + } + await ServeDeskRead(ctx, path, CachePolicy.CurationDeskRecommender); + } + + // Names go through HiveNames.IsAccountName, a character walk that knows the + // label rules a character class cannot express ("abc-", "a..b", "ab.cdef"). + // The permlink grammar is a plain class, anchored with \A and \z, not ^ and + // $: in .NET `$` also matches before a trailing newline, so "p\n" would pass + // a `$`-anchored check and travel into the upstream path. private static readonly Regex DeskPermlinkPattern = new(@"\A[a-z0-9-]{1,255}\z", RegexOptions.Compiled); /// @@ -139,17 +161,34 @@ public static async Task CurationDeskPost(HttpContext ctx) /// public static string? CurationDeskPostPath(string author, string permlink) { + // The name grammar has no room for a dot segment (every label is three + // letters or more); the permlink grammar has no dot at all. Both checks + // stay written out so the fence does not depend on that reading. if (author is "." or ".." || permlink is "." or "..") { return null; } - if (!DeskAuthorPattern.IsMatch(author) || !DeskPermlinkPattern.IsMatch(permlink)) + if (!HiveNames.IsAccountName(author) || !DeskPermlinkPattern.IsMatch(permlink)) { return null; } return $"curation/desk/post/{Uri.EscapeDataString(author)}/{Uri.EscapeDataString(permlink)}"; } + /// + /// Upstream path for one recommender's public scorecard, or null when the + /// value is not a plain Hive name. The name travels in the path, so it goes + /// through the same fence as : the name + /// grammar first, escaping as a second fence, then the dot-segment check for + /// the one case escaping cannot fix. The route takes no query parameters, so + /// every spelling of the question about one name is a single memo entry and a + /// single shared-cache key. + /// + public static string? CurationDeskRecommenderPath(string username) => + username is "." or ".." || !HiveNames.IsAccountName(username) + ? null + : $"curation/desk/recommenders/{Uri.EscapeDataString(username)}"; + private static IEnumerable> RawQuery(HttpContext ctx) { foreach (var kv in ctx.Request.Query) diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 33fb4683..3c76ec15 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -181,6 +181,7 @@ public static void Map(WebApplication app) app.MapGet("/private-api/curation-desk/roster", PrivateApi.CurationDeskRoster); app.MapGet("/private-api/curation-desk/recommendations", PrivateApi.CurationDeskRecommendations); app.MapGet("/private-api/curation-desk/post/{author}/{permlink}", PrivateApi.CurationDeskPost); + app.MapGet("/private-api/curation-desk/recommender/{username}", PrivateApi.CurationDeskRecommender); app.MapPost("/private-api/curation-desk/roster-feed", PrivateApi.CurationDeskRosterFeed); app.MapPost("/private-api/curation-desk/tick", PrivateApi.CurationDeskTick); app.MapPost("/private-api/curation-desk/mark", PrivateApi.CurationDeskMark); diff --git a/dotnet/EcencyApi/Infrastructure/CachePolicy.cs b/dotnet/EcencyApi/Infrastructure/CachePolicy.cs index 5733adef..089430a2 100644 --- a/dotnet/EcencyApi/Infrastructure/CachePolicy.cs +++ b/dotnet/EcencyApi/Infrastructure/CachePolicy.cs @@ -49,13 +49,16 @@ public static class CachePolicy /// The feed and the recommendations list move with every new post; status is /// the poll target and stays short; the roster changes when a curator is added /// or promoted, so it can stay put for minutes; a single post's recommenders - /// are optimistic on the client and confirmed from here. + /// are optimistic on the client and confirmed from here; a recommender's + /// scorecard is a rolling 90-day count recomputed in the background, so a + /// minute-old copy of it is still the same answer. /// public const string CurationDeskFeed = "public, max-age=0, s-maxage=30"; public const string CurationDeskStatus = "public, max-age=0, s-maxage=15"; public const string CurationDeskRoster = "public, max-age=0, s-maxage=600"; public const string CurationDeskRecommendations = "public, max-age=0, s-maxage=30"; public const string CurationDeskPost = "public, max-age=0, s-maxage=15"; + public const string CurationDeskRecommender = "public, max-age=0, s-maxage=60"; /// /// The `s-maxage` of a policy in seconds, or its `max-age` when it has no diff --git a/dotnet/EcencyApi/Infrastructure/HiveNames.cs b/dotnet/EcencyApi/Infrastructure/HiveNames.cs new file mode 100644 index 00000000..52b0d03b --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/HiveNames.cs @@ -0,0 +1,72 @@ +namespace EcencyApi.Infrastructure; + +/// +/// The Hive account name grammar, as the chain checks it in +/// is_valid_account_name: 3 to 16 characters overall, split on dots into +/// labels of at least 3 characters each, every label starting with a letter, +/// ending with a letter or digit, and made of a-z, 0-9 and +/// - in between. Labels are the part a character-class regex cannot +/// express: -ab, abc-, a..b, ab.cdef and +/// ... are all sixteen-or-fewer characters from the right alphabet and +/// none of them is a name. +/// +/// +/// One deliberate difference from the chain routine: a trailing dot +/// (abc.) is rejected here. The chain loop stops when the next label +/// would start past the end of the string, so it never looks at the empty last +/// label; a name that ends in a dot has no owner on chain in practice and is +/// not worth an authenticated upstream call. +/// +public static class HiveNames +{ + public const int MinLength = 3; + public const int MaxLength = 16; + + public static bool IsAccountName(string? name) + { + if (name == null || name.Length is < MinLength or > MaxLength) + { + return false; + } + + var begin = 0; + while (true) + { + var end = name.IndexOf('.', begin); + if (end < 0) + { + end = name.Length; + } + if (end - begin < MinLength) + { + return false; + } + if (!IsLetter(name[begin]) || !IsLetterOrDigit(name[end - 1])) + { + return false; + } + for (var i = begin + 1; i < end - 1; i++) + { + if (!IsLetterOrDigit(name[i]) && name[i] != '-') + { + return false; + } + } + if (end == name.Length) + { + return true; + } + // `end` sits on a dot; the next label starts after it. Reaching the + // end of the string here is the trailing-dot case. + begin = end + 1; + if (begin >= name.Length) + { + return false; + } + } + } + + private static bool IsLetter(char c) => c is >= 'a' and <= 'z'; + + private static bool IsLetterOrDigit(char c) => IsLetter(c) || c is >= '0' and <= '9'; +} diff --git a/dotnet/docker-compose.yml b/dotnet/docker-compose.yml index 829bd56c..696e6bfe 100644 --- a/dotnet/docker-compose.yml +++ b/dotnet/docker-compose.yml @@ -37,6 +37,10 @@ services: - SSR_RPC_NODES - SSR_RPC_MAX_FILLS - SSR_RPC_MAX_QUEUED_FILLS + # Curation desk (Handlers/PrivateApi.CurationDesk.cs): the shared token switches + # the routes on (unset = 503); the byte budget tunes the two memo stores. + - DESK_INTERNAL_TOKEN + - DESK_MEMO_BYTES ports: - "4000:4000" # Bound container logs so they can never grow unchecked on the host diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index c03a52bb..dc44e18e 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -256,6 +256,7 @@ def norm_body(text): "/private-api/curation-desk/roster::get", "/private-api/curation-desk/recommendations::get", "/private-api/curation-desk/post/x/x::get", + "/private-api/curation-desk/recommender/good-karma::get", ] + [ f"/private-api/curation-desk/{route}::{case}" for route in ("roster-feed", "tick", "mark", "mark-clear", "marks", "cursor", @@ -330,7 +331,7 @@ def norm_body(text): FAVORITE_TAGS_DIVERGENCE, "/private-api/favorite-tags-delete::badcode": FAVORITE_TAGS_DIVERGENCE, - # Curation desk routes, added after the port: five public reads and eight signed + # Curation desk routes, added after the port: six public reads and eight signed # writes. Same shape as the entries above; one entry per generated case. **{case: CURATION_DESK_DIVERGENCE for case in CURATION_DESK_ROUTES}, }