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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 10:58:45 +02:00
1 parent d06f684f0d
commit 99b5e2dbf3
6 files changed
+67 -8

No files matched your search

@@ -90,6 +90,39 @@ namespace PrivaPub.Tests.Federation
Assert.Equal((await DB.Default.Find<PostEntity>().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<string>());
var update = create.DeepClone().AsObject();
update.Remove("proof");
update["type"] = "Update";
update["object"]!["content"] = "<p>as edited</p>";
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<Job>().Match(j => j.DedupeKey.StartsWith("inbox|forwarded|" + update["id"]!.GetValue<string>() + "|") && 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<string>();
Assert.Contains("as edited", (await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token)).ContentHtml);
}
[Fact]
public async Task An_add_to_a_collection_on_another_server_is_no_threads()
{
@@ -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<PrivaPub.Models.Federation.Follower>().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<string>() == "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<PrivaPub.Models.Federation.Follower>().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()
{
@@ -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
@@ -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");
@@ -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)
{
+4 -3
View File
@@ -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;