diff --git a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs
new file mode 100644
index 00000000..62d22431
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs
@@ -0,0 +1,119 @@
+using System.Text.Json.Nodes;
+using EcencyApi.Handlers;
+using Xunit;
+
+namespace EcencyApi.Tests;
+
+///
+/// The favorite-tag check and delete handlers interpolate a body-supplied tag into
+/// an upstream path. A tag is an arbitrary string, so anything structural left
+/// unescaped is re-parsed when the string becomes a Uri, and the upstream call
+/// carries this service's credentials. Same reasoning as NotificationsPathTests.
+///
+public class FavoriteTagsPathTests
+{
+ [Fact]
+ public void RealRequestsAreUnchanged()
+ {
+ // Hive names and tags are unreserved characters; escaping must be a no-op for
+ // them or this would change every live request.
+ Assert.Equal(
+ "isfavoritetag/good-karma/photography",
+ PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "photography"));
+ Assert.Equal(
+ "favoriteTag/user.name/contest-2026",
+ PrivateApi.FavoriteTagPath("favoriteTag", "user.name", "contest-2026"));
+ }
+
+ [Fact]
+ public void LeadingHashIsEscapedNotDropped()
+ {
+ // Raw, `#` would truncate the path at a fragment. Escaped, it reaches the
+ // upstream, which normalises the tag and strips one leading hash itself.
+ Assert.Equal(
+ "isfavoritetag/good-karma/%23photography",
+ PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "#photography"));
+ }
+
+ [Theory]
+ // A missing tag must not become the literal "undefined" the way a missing
+ // account does in the favorites handlers: "undefined", "null", "true" and "123"
+ // are all valid tag names upstream, so templating would check or delete a follow
+ // the caller never named.
+ [InlineData("{}")]
+ [InlineData("{\"tag\": null}")]
+ [InlineData("{\"tag\": true}")]
+ [InlineData("{\"tag\": 123}")]
+ [InlineData("{\"tag\": \"\"}")]
+ [InlineData("{\"tag\": [\"photography\"]}")]
+ [InlineData("{\"tag\": {\"name\": \"photography\"}}")]
+ public void AbsentOrNonStringTagIsRejected(string json)
+ {
+ var body = (JsonObject)JsonNode.Parse(json)!;
+
+ Assert.Null(PrivateApi.FavoriteTagField(body));
+ }
+
+ [Fact]
+ public void StringTagIsReadAsIs()
+ {
+ // Read leniently and passed through untouched: normalising is the upstream's job.
+ var body = (JsonObject)JsonNode.Parse("{\"tag\": \"#Photography\"}")!;
+ Assert.Equal("#Photography", PrivateApi.FavoriteTagField(body));
+
+ // Lone surrogates are valid JSON string content and must not be dropped.
+ var lone = (JsonObject)JsonNode.Parse("{\"tag\": \"\\ud800\"}")!;
+ Assert.Equal("\ud800", PrivateApi.FavoriteTagField(lone));
+ }
+
+ [Theory]
+ // A slash would add path segments and address a different resource.
+ [InlineData("a", "b/c")]
+ [InlineData("a/b", "c")]
+ // A question mark would truncate the path and turn the rest into a query.
+ [InlineData("a", "b?x=1")]
+ // A hash would truncate the path at a fragment.
+ [InlineData("a", "b#f")]
+ // Whitespace and non-ASCII must not leak into the request line either.
+ [InlineData("a", "b c")]
+ [InlineData("a", "caf\u00e9")]
+ public void StructuralCharactersCannotEscapeTheirSegment(string username, string tag)
+ {
+ var path = PrivateApi.FavoriteTagPath("isfavoritetag", username, tag);
+
+ Assert.NotNull(path);
+ Assert.DoesNotContain("?x=1", path);
+ Assert.DoesNotContain("#f", path);
+ Assert.DoesNotContain(" ", path);
+ Assert.True(path!.All(c => c < 128));
+ // The only separators left are the two this builder wrote itself.
+ Assert.Equal(2, path.Split('/').Length - 1);
+ }
+
+ [Fact]
+ public void LoneSurrogateIsEncodedNotThrown()
+ {
+ // JSON strings are arbitrary UTF-16, so a lone surrogate reaches the helper as
+ // a real value (see TryGetStringLenient). On the runtime this service targets,
+ // EscapeDataString encodes it as U+FFFD rather than throwing, so the request
+ // stays on the handler's own path instead of falling through to the 500 page.
+ Assert.Equal(
+ "isfavoritetag/good-karma/%EF%BF%BD",
+ PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "\ud800"));
+ Assert.Equal(
+ "favoriteTag/good-karma/a%EF%BF%BDb",
+ PrivateApi.FavoriteTagPath("favoriteTag", "good-karma", "a\udc00b"));
+ }
+
+ [Theory]
+ // Dot segments cannot be fixed by escaping: Uri decodes %2E back to `.` before it
+ // removes dot segments, so they have to be rejected outright.
+ [InlineData(".", "photography")]
+ [InlineData("..", "photography")]
+ [InlineData("good-karma", ".")]
+ [InlineData("good-karma", "..")]
+ public void DotSegmentsAreRejected(string username, string tag)
+ {
+ Assert.Null(PrivateApi.FavoriteTagPath("isfavoritetag", username, tag));
+ }
+}
diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs
index 2bf92ee8..0f77d930 100644
--- a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs
+++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs
@@ -129,6 +129,118 @@ public static async Task FavoritesDelete(HttpContext ctx)
await Upstream.Pipe(ApiClient.ApiRequest($"favoriteUser/{username}/{account}", HttpMethod.Delete), ctx);
}
+ ///
+ /// The body's `tag`, or null when it is absent, not a string, or empty.
+ ///
+ /// The favorites handlers template a missing `account` into the literal
+ /// "undefined". A tag must not go that way: "undefined", "null", "true" and
+ /// "123" are all valid tag names upstream, so a request with no tag, or a
+ /// non-string one, would check or delete a follow the caller never named.
+ /// String extraction is lenient on purpose: JSON strings are arbitrary UTF-16.
+ ///
+ public static string? FavoriteTagField(JsonObject body)
+ {
+ if (!body.TryGetPropertyValue("tag", out var node) || node is not JsonValue value)
+ {
+ return null;
+ }
+
+ return JsVal.TryGetStringLenient(value, out var tag) && tag.Length > 0 ? tag : null;
+ }
+
+ ///
+ /// Upstream path for a followed-tag check or removal, or null when a value cannot
+ /// be expressed as a single path segment.
+ ///
+ /// The tag is a body string, so one 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. Same
+ /// reasoning as NotificationsPath. Real tags are lowercase words with digits and
+ /// hyphens, which EscapeDataString leaves byte-identical. A leading `#` a client
+ /// may send is escaped rather than dropped: normalising the tag is the upstream's
+ /// job, and it strips one.
+ ///
+ public static string? FavoriteTagPath(string action, string username, string tag)
+ {
+ if (IsDotSegment(username) || IsDotSegment(tag))
+ {
+ return null;
+ }
+
+ return $"{action}/{Uri.EscapeDataString(username)}/{Uri.EscapeDataString(tag)}";
+ }
+
+ public static async Task FavoriteTags(HttpContext ctx)
+ {
+ var body = await ctx.ReadBody();
+ var username = await ValidateCode(body);
+ if (username == null)
+ {
+ await ctx.SendText(401, "Unauthorized");
+ return;
+ }
+ await Upstream.Pipe(
+ ApiClient.ApiRequest($"favorite-tags/{username}", HttpMethod.Get, null, null, UserData2Js.Query(ctx)),
+ ctx);
+ }
+
+ public static async Task FavoriteTagsCheck(HttpContext ctx)
+ {
+ var body = await ctx.ReadBody();
+ var username = await ValidateCode(body);
+ if (username == null)
+ {
+ await ctx.SendText(401, "Unauthorized");
+ return;
+ }
+ var tag = FavoriteTagField(body);
+ var path = tag == null ? null : FavoriteTagPath("isfavoritetag", username, tag);
+ if (path == null)
+ {
+ await ctx.SendText(400, "Invalid tag");
+ return;
+ }
+ await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx);
+ }
+
+ public static async Task FavoriteTagsAdd(HttpContext ctx)
+ {
+ var body = await ctx.ReadBody();
+ var username = await ValidateCode(body);
+ if (username == null)
+ {
+ await ctx.SendText(401, "Unauthorized");
+ return;
+ }
+ var tag = FavoriteTagField(body);
+ if (tag == null)
+ {
+ await ctx.SendText(400, "Invalid tag");
+ return;
+ }
+ var data = new JsonObject { ["username"] = username, ["tag"] = tag };
+ await Upstream.Pipe(ApiClient.ApiRequest("favorite-tag", HttpMethod.Post, null, data), ctx);
+ }
+
+ public static async Task FavoriteTagsDelete(HttpContext ctx)
+ {
+ var body = await ctx.ReadBody();
+ var username = await ValidateCode(body);
+ if (username == null)
+ {
+ await ctx.SendText(401, "Unauthorized");
+ return;
+ }
+ var tag = FavoriteTagField(body);
+ var path = tag == null ? null : FavoriteTagPath("favoriteTag", username, tag);
+ if (path == null)
+ {
+ await ctx.SendText(400, "Invalid tag");
+ return;
+ }
+ await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Delete), ctx);
+ }
+
public static async Task Fragments(HttpContext ctx)
{
var body = await ctx.ReadBody();
diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs
index dd74c8d6..9348c0a7 100644
--- a/dotnet/EcencyApi/Handlers/Routes.cs
+++ b/dotnet/EcencyApi/Handlers/Routes.cs
@@ -127,6 +127,10 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/favorites-check", PrivateApi.FavoritesCheck);
app.MapPost("/private-api/favorites-add", PrivateApi.FavoritesAdd);
app.MapPost("/private-api/favorites-delete", PrivateApi.FavoritesDelete);
+ app.MapPost("/private-api/favorite-tags", PrivateApi.FavoriteTags);
+ app.MapPost("/private-api/favorite-tags-check", PrivateApi.FavoriteTagsCheck);
+ app.MapPost("/private-api/favorite-tags-add", PrivateApi.FavoriteTagsAdd);
+ app.MapPost("/private-api/favorite-tags-delete", PrivateApi.FavoriteTagsDelete);
app.MapPost("/private-api/fragments", PrivateApi.Fragments);
app.MapPost("/private-api/fragments-add", PrivateApi.FragmentsAdd);
app.MapPost("/private-api/fragments-update", PrivateApi.FragmentsUpdate);
diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py
index c65239e3..2ee86618 100644
--- a/dotnet/parity/driver.py
+++ b/dotnet/parity/driver.py
@@ -62,6 +62,9 @@
"/private-api/account-create-friend": {"username": "newuser123",
"email": "x@example.com", "friend": True},
"/private-api/subscribe": {"email": "x@example.com"},
+ "/private-api/favorite-tags-check": {"code": "invalid", "tag": "photography"},
+ "/private-api/favorite-tags-add": {"code": "invalid", "tag": "photography"},
+ "/private-api/favorite-tags-delete": {"code": "invalid", "tag": "photography"},
"/private-api/notifications": {"code": "invalid", "filter": "", "since": 0,
"limit": 50, "user": "good-karma"},
"/private-api/report": {"type": "content", "data": {"a": 1}},
@@ -224,6 +227,13 @@ def norm_body(text):
"::pop and ::badcode for every POST route, which is why all three appear here."
)
+FAVORITE_TAGS_DIVERGENCE = (
+ "Followed-hashtag routes added after the port (the favorites passthroughs, keyed by "
+ "tag instead of account). The reference build has no such route and answers 404; "
+ "this one validates the signed code and answers 401 or proxies. Deterministic and "
+ "additive -- no behavior the reference ever had is changing."
+)
+
AI_IMAGES_DIVERGENCE = (
"Per-user AI image history route added after the port (ePoints users//ai-images). "
"The reference build has no such route and answers 404; this one validates the "
@@ -272,6 +282,32 @@ def norm_body(text):
AI_IMAGES_DIVERGENCE,
"/private-api/ai-images::badcode":
AI_IMAGES_DIVERGENCE,
+ # Followed-hashtag passthroughs, added after the port. Same shape as the entries
+ # above: additive routes the reference build never had.
+ "/private-api/favorite-tags::min":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags::pop":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags::badcode":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-check::min":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-check::pop":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-check::badcode":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-add::min":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-add::pop":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-add::badcode":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-delete::min":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-delete::pop":
+ FAVORITE_TAGS_DIVERGENCE,
+ "/private-api/favorite-tags-delete::badcode":
+ FAVORITE_TAGS_DIVERGENCE,
}
# Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list