From 460252ae0b720a08741d302828cd87a5611e539c Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 5 Sep 2026 17:58:01 +0000 Subject: [PATCH 1/3] feat: serve the public recommender scorecard read Sixth public curation desk read: GET /private-api/curation-desk/recommender/{username} pipes to curation/desk/recommenders/ through the same path as the other public reads, so the shared secret, the byte memo, the single flight and the public payload fence all apply to it unchanged. The unconfigured 503 is decided before the route value is read, so a dark desk answers every route alike instead of reporting on this one's name grammar. The name is validated against the Hive name grammar and escaped before the URL is built; the route takes no query parameters, so one name is one memo entry and one shared-cache key. Its own policy holds a scorecard for a minute: the numbers behind it are a rolling 90-day count recomputed in the background. --- dotnet/EcencyApi.Tests/CachePolicyTests.cs | 2 + .../EcencyApi.Tests/CurationDeskAuthTests.cs | 141 ++++++++++++++++++ .../CurationDeskPayloadTests.cs | 43 ++++++ .../CurationDeskPublicPayloadTests.cs | 2 +- .../CurationDeskTestSupport.cs | 3 + .../Handlers/PrivateApi.CurationDesk.cs | 39 ++++- dotnet/EcencyApi/Handlers/Routes.cs | 1 + .../EcencyApi/Infrastructure/CachePolicy.cs | 5 +- dotnet/parity/driver.py | 3 +- 9 files changed, 234 insertions(+), 5 deletions(-) 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..f4e7d661 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -360,4 +360,47 @@ 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/" + 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")] + // `$` 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/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index 568171f8..6ac617b5 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,6 +122,27 @@ public static async Task CurationDeskPost(HttpContext ctx) await ServeDeskRead(ctx, path, CachePolicy.CurationDeskPost); } + // 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); + } + // \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. @@ -150,6 +171,20 @@ public static async Task CurationDeskPost(HttpContext ctx) 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 ".." || !DeskAuthorPattern.IsMatch(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/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}, } From 62621500356d101643b23d9a43d6eb0f20c04400 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 5 Sep 2026 18:13:30 +0000 Subject: [PATCH 2/3] fix: check desk names label by label, not as a character class A `[a-z0-9.-]{3,16}` class accepts `-ab`, `abc-`, `a..b`, `ab.cdef` and `...`, none of which is a Hive account name, so those requests went upstream with the desk token instead of answering 400. `HiveNames` walks the name the way the chain's `is_valid_account_name` does: labels of three or more characters, each starting with a letter and ending with a letter or digit; a trailing dot is rejected as well. Both desk path helpers use it. Regression cases cover each rejected shape and the valid edges (`a-b`, multi-label names). --- .../CurationDeskPayloadTests.cs | 26 +++++++ dotnet/EcencyApi.Tests/HiveNamesTests.cs | 62 ++++++++++++++++ .../Handlers/PrivateApi.CurationDesk.cs | 16 +++-- dotnet/EcencyApi/Infrastructure/HiveNames.cs | 72 +++++++++++++++++++ 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/HiveNamesTests.cs create mode 100644 dotnet/EcencyApi/Infrastructure/HiveNames.cs diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index f4e7d661..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")] @@ -370,6 +380,10 @@ public void ARecommenderNameMapsToTheUpstreamPathUnchanged() 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))); } @@ -390,6 +404,18 @@ public void ARecommenderNameMapsToTheUpstreamPathUnchanged() [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) 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 6ac617b5..bf930925 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -143,10 +143,11 @@ public static async Task CurationDeskRecommender(HttpContext ctx) await ServeDeskRead(ctx, path, CachePolicy.CurationDeskRecommender); } - // \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); + // 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); /// @@ -160,11 +161,14 @@ public static async Task CurationDeskRecommender(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; } @@ -181,7 +185,7 @@ public static async Task CurationDeskRecommender(HttpContext ctx) /// single shared-cache key. /// public static string? CurationDeskRecommenderPath(string username) => - username is "." or ".." || !DeskAuthorPattern.IsMatch(username) + username is "." or ".." || !HiveNames.IsAccountName(username) ? null : $"curation/desk/recommenders/{Uri.EscapeDataString(username)}"; 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'; +} From b424406451005ae0a187a3ae189c52d3a19fb2e6 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 5 Sep 2026 18:40:34 +0000 Subject: [PATCH 3/3] chore: pass the curation desk token and memo budget through the stack file The service only receives the variables the stack file names, so a `docker stack deploy` from this file left the desk routes at 503 whatever the host exported. Names only; values stay on the host. --- dotnet/docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) 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