diff --git a/dotnet/EcencyApi.Tests/CachePolicyTests.cs b/dotnet/EcencyApi.Tests/CachePolicyTests.cs
new file mode 100644
index 00000000..0f591eb4
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/CachePolicyTests.cs
@@ -0,0 +1,58 @@
+using EcencyApi.Infrastructure;
+using Xunit;
+
+namespace EcencyApi.Tests;
+
+///
+/// The cache policies are a public contract: `public` lets shared caches store a
+/// response, and the max-age is how long a wrong one would stay wrong. Pin both
+/// the opt-in rule and the shape of each policy.
+///
+public class CachePolicyTests
+{
+ public static TheoryData AllPolicies() =>
+ new() { CachePolicy.ProMembers, CachePolicy.Announcements, CachePolicy.PostTips };
+
+ [Theory]
+ [MemberData(nameof(AllPolicies))]
+ public void EveryPolicyIsPubliclyCacheableWithAMaxAge(string policy)
+ {
+ Assert.StartsWith("public, max-age=", policy);
+ Assert.DoesNotContain("no-store", policy);
+ Assert.DoesNotContain("private", policy);
+ }
+
+ [Theory]
+ [MemberData(nameof(AllPolicies))]
+ public void APolicyOnlyAppliesToASuccessfulResponse(string policy)
+ {
+ Assert.Equal(policy, CachePolicy.ForStatus(200, policy));
+
+ // Pipe() turns upstream transport failures into these after the handler
+ // has already attached the policy. Caching one would keep a healthy
+ // endpoint broken for the whole max-age.
+ Assert.Null(CachePolicy.ForStatus(504, policy));
+ Assert.Null(CachePolicy.ForStatus(500, policy));
+
+ // Upstream error passthroughs must not be cached either.
+ Assert.Null(CachePolicy.ForStatus(404, policy));
+ Assert.Null(CachePolicy.ForStatus(401, policy));
+ Assert.Null(CachePolicy.ForStatus(429, policy));
+ }
+
+ [Fact]
+ public void AnnouncementsOutliveTheOtherPolicies()
+ {
+ // Announcements are a compile-time constant, so they can only change on a
+ // deploy; the proxied endpoints track data that moves independently.
+ Assert.True(MaxAge(CachePolicy.Announcements) > MaxAge(CachePolicy.ProMembers));
+ Assert.True(MaxAge(CachePolicy.ProMembers) > MaxAge(CachePolicy.PostTips));
+ }
+
+ private static int MaxAge(string policy)
+ {
+ var token = policy.Split(',').Select(p => p.Trim())
+ .Single(p => p.StartsWith("max-age=", StringComparison.Ordinal));
+ return int.Parse(token["max-age=".Length..]);
+ }
+}
diff --git a/dotnet/EcencyApi.Tests/PostTipsPathTests.cs b/dotnet/EcencyApi.Tests/PostTipsPathTests.cs
new file mode 100644
index 00000000..07bddef9
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/PostTipsPathTests.cs
@@ -0,0 +1,83 @@
+using EcencyApi.Handlers;
+using Xunit;
+
+namespace EcencyApi.Tests;
+
+///
+/// The tips handlers build an upstream path by interpolating caller-supplied
+/// author and permlink values. Route values arrive percent-decoded and body
+/// values are arbitrary strings, so anything structural left unescaped is
+/// re-parsed when the string becomes a Uri — and the upstream call carries this
+/// service's credentials, so a redirected path is a real problem, not a cosmetic
+/// one.
+///
+public class PostTipsPathTests
+{
+ [Fact]
+ public void RealAuthorsAndPermlinksAreUnchanged()
+ {
+ // Hive names and permlinks are unreserved characters; escaping must be a
+ // no-op for them or this would change every live request.
+ Assert.Equal(
+ "post-tips/good-karma/my-post-title-2026",
+ PrivateApi.PostTipsPath("good-karma", "my-post-title-2026"));
+ Assert.Equal(
+ "post-tips/user.name/a_b-c.d~e",
+ PrivateApi.PostTipsPath("user.name", "a_b-c.d~e"));
+ }
+
+ [Theory]
+ // A slash would add path segments and address a different resource.
+ [InlineData("a/b", "p")]
+ [InlineData("a", "p/q")]
+ // A question mark would truncate the path and turn the rest into a query.
+ [InlineData("a?x=1", "p")]
+ [InlineData("a", "p?x=1")]
+ // A hash would truncate the path at a fragment.
+ [InlineData("a#f", "p")]
+ // Dots inside a longer segment are ordinary characters, not structure.
+ [InlineData("x..y", "p")]
+ public void StructuralCharactersCannotEscapeTheirSegment(string author, string permlink)
+ {
+ var path = PrivateApi.PostTipsPath(author, permlink);
+ Assert.NotNull(path);
+
+ // Exactly three segments: the prefix plus one each for author and permlink.
+ Assert.Equal(3, path!.Split('/').Length);
+ Assert.StartsWith("post-tips/", path);
+ Assert.DoesNotContain("?", path);
+ Assert.DoesNotContain("#", path);
+
+ // And the whole thing still resolves to a path under post-tips rather
+ // than somewhere else on the upstream host.
+ var resolved = new Uri(new Uri("https://upstream.invalid/api/"), path);
+ Assert.StartsWith("/api/post-tips/", resolved.AbsolutePath);
+ Assert.Equal("", resolved.Query);
+ }
+
+ [Theory]
+ [InlineData("..", "p")]
+ [InlineData("a", "..")]
+ [InlineData(".", "p")]
+ [InlineData("a", ".")]
+ public void DotSegmentsAreRejectedRatherThanEscaped(string author, string permlink)
+ {
+ // Escaping cannot neutralise these: `.` and `..` are unreserved so
+ // EscapeDataString passes them through, and Uri decodes `%2E` back to `.`
+ // before removing dot segments, so hand-encoding them resolves upward too.
+ // Verified against Uri directly:
+ Assert.Equal("/api/p", new Uri(new Uri("https://upstream.invalid/api/"), "post-tips/../p").AbsolutePath);
+ Assert.Equal("/api/p", new Uri(new Uri("https://upstream.invalid/api/"), "post-tips/%2E%2E/p").AbsolutePath);
+
+ Assert.Null(PrivateApi.PostTipsPath(author, permlink));
+ }
+
+ [Fact]
+ public void EmptyAndMissingValuesStayInsideTheirSegment()
+ {
+ // Missing route params and absent body keys interpolate as "undefined";
+ // both must remain a single inert segment.
+ Assert.Equal("post-tips/undefined/undefined", PrivateApi.PostTipsPath("undefined", "undefined"));
+ Assert.Equal(3, PrivateApi.PostTipsPath("", "")!.Split('/').Length);
+ }
+}
diff --git a/dotnet/EcencyApi/Handlers/Announcements.cs b/dotnet/EcencyApi/Handlers/Announcements.cs
index 10417172..d7341be7 100644
--- a/dotnet/EcencyApi/Handlers/Announcements.cs
+++ b/dotnet/EcencyApi/Handlers/Announcements.cs
@@ -40,8 +40,11 @@ public static class Announcements
public static partial class PrivateApi
{
// GET ^/private-api/announcements$
+ // The payload is the constant above, so it only changes when this repo is
+ // deployed; clients can hold on to it between page views.
public static async Task GetAnnouncement(HttpContext ctx)
{
+ ctx.CacheWhenOk(CachePolicy.Announcements);
await ctx.SendJson(200, Announcements.Json());
}
}
diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
index 67f9da08..5f65bec2 100644
--- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
+++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
@@ -248,9 +248,12 @@ public static async Task RewardedCommunities(HttpContext ctx)
}
// Public list of Pro usernames for the Pro badge roster. No auth: this is a
- // public, cached list served straight from the backend.
+ // public, cached list served straight from the backend. The response is the
+ // same for every caller, so it carries a Cache-Control and clients can reuse
+ // it across page views instead of refetching the roster on each one.
public static async Task ProMembers(HttpContext ctx)
{
+ ctx.CacheWhenOk(CachePolicy.ProMembers);
await Upstream.Pipe(ApiClient.ApiRequest("pro-members", HttpMethod.Get), ctx);
}
}
diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
index e483ca77..c1d711e4 100644
--- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
+++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
@@ -263,12 +263,69 @@ public static async Task RequestDelete(HttpContext ctx)
});
}
+ ///
+ /// A segment URL resolution reads as structure rather than as a name.
+ ///
+ /// These cannot be escaped away: `.` and `..` are unreserved, so
+ /// EscapeDataString passes them through, and percent-encoding them by hand
+ /// does not help either because Uri decodes `%2E` back to `.` before it
+ /// removes dot segments. Rejecting is the only option that holds.
+ ///
+ private static bool IsDotSegment(string value) => value is "." or "..";
+
+ ///
+ /// Upstream path for post tips, or null when a value cannot be expressed as
+ /// a single path segment.
+ ///
+ /// Author and permlink are escaped as path segments. Route values arrive
+ /// percent-decoded and body values are arbitrary strings, so a value carrying
+ /// `/`, `?` or `#` would otherwise be re-parsed as URL structure once this
+ /// string becomes a Uri — addressing a different upstream resource, with this
+ /// service's credentials attached.
+ ///
+ /// Authors and permlinks are made of unreserved characters, which
+ /// EscapeDataString leaves byte-identical, so real traffic is unaffected.
+ ///
+ public static string? PostTipsPath(string author, string permlink) =>
+ IsDotSegment(author) || IsDotSegment(permlink)
+ ? null
+ : $"post-tips/{Uri.EscapeDataString(author)}/{Uri.EscapeDataString(permlink)}";
+
public static async Task Tips(HttpContext ctx)
{
var body = await ctx.ReadBody();
var author = MiscBodyInterp(body, "author");
var permlink = MiscBodyInterp(body, "permlink");
- await Upstream.Pipe(ApiClient.ApiRequest($"post-tips/{author}/{permlink}", HttpMethod.Get), ctx);
+ var path = PostTipsPath(author, permlink);
+ if (path == null)
+ {
+ await ctx.SendText(400, "Invalid author or permlink");
+ return;
+ }
+ await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx);
+ }
+
+ ///
+ /// GET twin of , same upstream call and same body.
+ ///
+ /// Tips are a plain read keyed only by author/permlink, but the original
+ /// endpoint is a POST, and a POST response is uncacheable by definition — so
+ /// every mount refetched it. This variant is addressable by URL and carries a
+ /// Cache-Control, which lets a client reuse it. The POST stays for clients
+ /// that have not moved over.
+ ///
+ public static async Task TipsGet(HttpContext ctx)
+ {
+ var author = MiscRouteParam(ctx, "author");
+ var permlink = MiscRouteParam(ctx, "permlink");
+ var path = PostTipsPath(author, permlink);
+ if (path == null)
+ {
+ await ctx.SendText(400, "Invalid author or permlink");
+ return;
+ }
+ ctx.CacheWhenOk(CachePolicy.PostTips);
+ await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx);
}
public static async Task GameGet(HttpContext ctx)
diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs
index 347a6193..50648a0c 100644
--- a/dotnet/EcencyApi/Handlers/Routes.cs
+++ b/dotnet/EcencyApi/Handlers/Routes.cs
@@ -71,6 +71,9 @@ public static void Map(WebApplication app)
app.MapGet("/private-api/waves/following", PrivateApi.WavesFollowing);
app.MapGet("/private-api/waves/trending/tags", PrivateApi.WavesTrendingTags);
app.MapGet("/private-api/waves/trending/authors", PrivateApi.WavesTrendingAuthors);
+ // Cacheable twin of the POST /private-api/post-tips below; both hit the
+ // same upstream. Registered as GET so the response can be reused.
+ app.MapGet("/private-api/post-tips/{author}/{permlink}", PrivateApi.TipsGet);
// ---- Private Api (POST) ----
app.MapPost("/private-api/comment-history", PrivateApi.CommentHistory);
diff --git a/dotnet/EcencyApi/Infrastructure/CachePolicy.cs b/dotnet/EcencyApi/Infrastructure/CachePolicy.cs
new file mode 100644
index 00000000..b8057b70
--- /dev/null
+++ b/dotnet/EcencyApi/Infrastructure/CachePolicy.cs
@@ -0,0 +1,46 @@
+namespace EcencyApi.Infrastructure;
+
+///
+/// Cache-Control policies for the handful of private-API reads that are the same
+/// for every visitor.
+///
+/// Most endpoints here are per-user or per-request and deliberately carry no
+/// Cache-Control at all. The edge treats a missing Cache-Control as "do not
+/// store", so a response only becomes reusable once a handler opts in — which is
+/// why these policies are explicit per endpoint rather than a global default.
+///
+/// Only opt an endpoint in when its body depends on nothing but the URL: no
+/// authenticated user, no request body, no per-caller variation. `public` here
+/// means shared caches may store it, so getting that wrong would let one
+/// visitor's response reach another.
+///
+public static class CachePolicy
+{
+ ///
+ /// Pro badge roster: one global list, no auth. It only moves when someone
+ /// starts or stops being a Pro member, so a short fresh window plus a long
+ /// revalidate window keeps badges correct without refetching per page view.
+ ///
+ public const string ProMembers = "public, max-age=600, stale-while-revalidate=3600";
+
+ ///
+ /// Announcements are a compile-time constant in this repo (Announcements.cs),
+ /// so the body can only change on deploy.
+ ///
+ public const string Announcements = "public, max-age=1800, stale-while-revalidate=86400";
+
+ ///
+ /// Tips for a single post, keyed entirely by author/permlink and readable
+ /// without auth. Tips arrive at any time, so the fresh window stays short;
+ /// the revalidate window is what absorbs repeat reads within one visit.
+ ///
+ public const string PostTips = "public, max-age=60, stale-while-revalidate=600";
+
+ ///
+ /// A policy applies only to a successful response. Handlers attach it before
+ /// the upstream call resolves, and can still turn
+ /// a transport failure into 504/500 afterwards — caching either would pin an
+ /// error in front of a healthy endpoint for the whole max-age.
+ ///
+ public static string? ForStatus(int status, string policy) => status == 200 ? policy : null;
+}
diff --git a/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs
index 35a39c7f..4be22624 100644
--- a/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs
+++ b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs
@@ -96,4 +96,26 @@ public static async Task SendJson(this HttpContext ctx, int status, JsonNode? no
/// Raw node for a body field (undefined -> null).
public static JsonNode? Field(this JsonObject body, string key) =>
body.TryGetPropertyValue(key, out var v) ? v : null;
+
+ ///
+ /// Attach a to this response, applied only if it
+ /// finishes as a 200.
+ ///
+ /// The check has to happen at header-flush time, not at call time: handlers
+ /// call this before awaiting the upstream, and Pipe() can still replace the
+ /// status with 504/500 afterwards. OnStarting runs once the status is final
+ /// and before anything is written.
+ ///
+ public static void CacheWhenOk(this HttpContext ctx, string policy)
+ {
+ ctx.Response.OnStarting(() =>
+ {
+ var value = CachePolicy.ForStatus(ctx.Response.StatusCode, policy);
+ if (value != null)
+ {
+ ctx.Response.Headers.CacheControl = value;
+ }
+ return Task.CompletedTask;
+ });
+ }
}
diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py
index ed367eac..e990cc71 100644
--- a/dotnet/parity/driver.py
+++ b/dotnet/parity/driver.py
@@ -228,6 +228,11 @@ def norm_body(text):
"Same request-delete rerouting as ::min.",
"/private-api/request-delete::badcode":
"Same request-delete rerouting as ::min.",
+ "/private-api/post-tips/x/x::get":
+ "New cacheable GET twin of POST /private-api/post-tips. The reference build "
+ "has no such route, so it answers with the unmatched-GET template page while "
+ "this one proxies the tips payload. Deterministic and additive; the POST case "
+ "still covers the shared upstream behavior.",
}
# Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list