From 99b5e2dbf30a1c34f8338b8a79fea072f342ca08 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 10:58:45 +0200 Subject: [PATCH] A follow of a persona's profile page; an Update under its Create's id Forte follows a persona by its profile page (/@name, WebFinger's alias) rather than its actor's id: Follow and Undo{Follow} now find the persona by either (it was a 404). Forte also sends an edit only added to the thread's context (FEP-171b), under the same activity id as the post's Create: the unwrapping now tells two activities that share an id apart by what they carry, as the inbox does, so the Update is not taken for a copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- .../Federation/ConversationContainerTests.cs | 33 +++++++++++++++++++ .../Federation/InboundRoutingTests.cs | 21 ++++++++++++ .../Inbox/ConversationContainers.cs | 10 ++++-- .../Inbox/Handlers/FollowHandler.cs | 2 +- .../Federation/Inbox/Handlers/UndoHandler.cs | 2 +- PrivaPub/Federation/Inbox/InboxReceiver.cs | 7 ++-- 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/PrivaPub.Tests/Federation/ConversationContainerTests.cs b/PrivaPub.Tests/Federation/ConversationContainerTests.cs index b643c9a..3990768 100644 --- a/PrivaPub.Tests/Federation/ConversationContainerTests.cs +++ b/PrivaPub.Tests/Federation/ConversationContainerTests.cs @@ -90,6 +90,39 @@ namespace PrivaPub.Tests.Federation Assert.Equal((await DB.Default.Find().Match(p => p.ObjectURI == rootId).ExecuteFirstAsync(Token)).ID, stored.AnsweringToPostId); } + // Forte gives a post's Update the id of its Create, and sends it only added to the thread's context + [Fact] + public async Task An_update_added_under_its_creates_id_is_taken_as_the_edit() + { + var (_, alice) = await _harness.Persona("alice"); + var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); + await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); + var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}"; + var create = owner.Prove(Create(owner, "as first written", context)); + JsonObject Added(JsonObject inner) => owner.Prove(new JsonObject + { + ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = inner, + ["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id } + }); + await _harness.Deliver(owner, "/human-centipede", Added(create)); + await RunQueued(create["id"]!.GetValue()); + var update = create.DeepClone().AsObject(); + update.Remove("proof"); + update["type"] = "Update"; + update["object"]!["content"] = "

as edited

"; + update["object"]!["updated"] = DateTime.UtcNow.AddSeconds(1).ToString("O"); + owner.Prove(update); + + await _harness.Deliver(owner, "/human-centipede", Added(update)); + var job = await DB.Default.Find().Match(j => j.DedupeKey.StartsWith("inbox|forwarded|" + update["id"]!.GetValue() + "|") && j.State == JobState.Pending) + .ExecuteFirstAsync(Token); + Assert.NotNull(job); + Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result); + + var noteId = create["object"]!["id"]!.GetValue(); + Assert.Contains("as edited", (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token)).ContentHtml); + } + [Fact] public async Task An_add_to_a_collection_on_another_server_is_no_threads() { diff --git a/PrivaPub.Tests/Federation/InboundRoutingTests.cs b/PrivaPub.Tests/Federation/InboundRoutingTests.cs index 1f0f347..a8f165f 100644 --- a/PrivaPub.Tests/Federation/InboundRoutingTests.cs +++ b/PrivaPub.Tests/Federation/InboundRoutingTests.cs @@ -191,6 +191,27 @@ namespace PrivaPub.Tests.Federation Assert.Equal(300, result.RetryAfterSeconds); } + // Forte follows a persona by its profile page, as WebFinger names it, rather than by its actor's id; so does its Undo + [Fact] + public async Task A_follow_of_a_personas_profile_page_follows_the_persona() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var forte = new RemoteActor(_harness.Peer, "forte"); + var follow = new JsonObject { ["id"] = NewId(forte, "follow"), ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl }; + + Assert.Equal(202, (await _harness.Deliver(forte, "/human-centipede", follow)).StatusCode); + Assert.True(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id && f.IsAccepted).ExecuteAnyAsync(token)); + Assert.Contains(await _harness.Outgoing(forte.Id + "/inbox"), a => a["type"]!.GetValue() == "Accept"); + + await _harness.Deliver(forte, "/human-centipede", new JsonObject + { + ["id"] = NewId(forte, "undo"), ["type"] = "Undo", ["actor"] = forte.Id, + ["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl } + }); + Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id).ExecuteAnyAsync(token)); + } + [Fact] public async Task A_join_of_a_local_post_is_answered_with_ignore() { diff --git a/PrivaPub/Federation/Inbox/ConversationContainers.cs b/PrivaPub/Federation/Inbox/ConversationContainers.cs index e32710a..03f6da8 100644 --- a/PrivaPub/Federation/Inbox/ConversationContainers.cs +++ b/PrivaPub/Federation/Inbox/ConversationContainers.cs @@ -32,9 +32,13 @@ namespace PrivaPub.Federation.Inbox Arrival.Drop("container-unusable"); return true; } - var payload = new InboxPayload(actorUri, inner.ToJsonString(), "shared", ReceivedAt: DateTime.UtcNow, ForwardedBy: owner.ActorURI); - var queued = await queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload), new Uri(actorUri).Host.ToLowerInvariant(), - "inbox|forwarded|" + innerId, token); + var payload = JsonSerializer.Serialize(new InboxPayload(actorUri, inner.ToJsonString(), "shared", ReceivedAt: DateTime.UtcNow, ForwardedBy: owner.ActorURI)); + var host = new Uri(actorUri).Host.ToLowerInvariant(); + var dedupe = "inbox|forwarded|" + innerId; + var queued = await queue.Enqueue(JobKind.ProcessInbox, payload, host, dedupe, token); + // one id for two activities (Forte's Update of a post has its Create's id): what each carries tells them apart + if (!queued && InboxReceiver.CarriesOther(await queue.Payload(dedupe, token), inner)) + queued = await queue.Enqueue(JobKind.ProcessInbox, payload, host, $"{dedupe}|{InboxReceiver.Digest(inner)}", token); if (queued) Arrival.Accept("unwrapped"); else diff --git a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs index 6135582..4b560c1 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs @@ -43,7 +43,7 @@ namespace PrivaPub.Federation.Inbox.Handlers var follow = activity; var follower = actor; - var target = await _localActors.FindByUri(Id(follow["object"]), token); + var target = await _localActors.FindByAddress(Id(follow["object"]), token); if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) { Arrival.Drop("unknown-recipient"); diff --git a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs index 051d789..7efa13b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs @@ -83,7 +83,7 @@ namespace PrivaPub.Federation.Inbox.Handlers { var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token); var targetUri = inner is JsonObject ? Id(inner["object"]) : default; - var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token); + var target = targetUri == default ? default : await _localActors.FindByAddress(targetUri, token); var undone = false; foreach (var follower in followers) { diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 43318c3..5f6b558 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -225,7 +225,7 @@ namespace PrivaPub.Federation.Inbox static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default; - static bool CarriesOther(string earlierPayload, JsonNode activity) + public static bool CarriesOther(string earlierPayload, JsonNode activity) { if (earlierPayload == default) return false; @@ -235,7 +235,7 @@ namespace PrivaPub.Federation.Inbox } // what an activity carries, short: its type, actor and object - static string Digest(JsonNode activity) => + public static string Digest(JsonNode activity) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes( $"{Value(activity, "type")}\n{Id(activity["actor"])}\n{activity["object"]?.ToJsonString()}")))[..16].ToLowerInvariant(); @@ -252,7 +252,8 @@ namespace PrivaPub.Federation.Inbox switch (type) { case "Follow": - var target = await _localActors.FindByUri(Id(inner), token); + // (by its actor's id, or the profile page Forte names instead) + var target = await _localActors.FindByAddress(Id(inner), token); if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient"); break;