diff --git a/CLAUDE.md b/CLAUDE.md
index f48b557..b40ab45 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -178,7 +178,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- an activity is queued once per id, but an id that comes back carrying another type, actor or object is a second
activity, not a copy (Friendica's ids are `uniqid()`, which two of its processes can share), and is queued apart;
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
- object is refetched from its own origin.
+ object is refetched from its own origin. One attributed to another account of the actor's server (Mobilizon's
+ organiser and the group's event) is taken as that server has it, and deleted once it answers 404 or 410.
5. **Status codes:**
- bad or missing signature: **401**;
- malformed or forged body: **400**;
diff --git a/FEDERATION.md b/FEDERATION.md
index 9459d97..ba6cc8f 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -225,6 +225,10 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
+- **Another account of the same server.** An object attributed to another account of the actor's own server (Mobilizon's
+ organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is
+ taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is
+ refused (400).
- **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with
its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs
index 56b2fe9..7d74069 100644
--- a/PrivaPub.Tests/Federation/InboxGapTests.cs
+++ b/PrivaPub.Tests/Federation/InboxGapTests.cs
@@ -116,6 +116,50 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(FollowState.Accepted, (await Of(jun)).State);
}
+ // Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used
+ // to be refused as misattributed (400), so the edit was lost and the deletion left the event in place
+ [Fact]
+ public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var (_, alice) = await _harness.Persona("alice");
+ var group = new RemoteActor(_harness.Peer, "group", type: "Group");
+ var organiser = new RemoteActor(_harness.Peer, "organiser");
+ await Follows(alice.Id, group);
+ var path = $"/events/{Guid.NewGuid():N}";
+ JsonObject Event(string name) => new()
+ {
+ ["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "
bring bread
",
+ ["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z",
+ ["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
+ };
+ _harness.Peer.Serve(path, Event("A picnic").ToJsonString());
+ Task Stored() => DB.Default.Find().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token);
+
+ var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic")));
+ Assert.Equal(202, created.StatusCode);
+ Assert.Equal(group.Id, (await Stored()).ActorURI);
+
+ var moved = Event("A picnic, moved indoors");
+ moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
+ _harness.Peer.Serve(path, moved.ToJsonString());
+ await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims")));
+ Assert.Equal("A picnic, moved indoors", (await Stored()).Title);
+
+ await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
+ Assert.NotNull(await Stored());
+ _harness.Peer.Answer(path, 410);
+ await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
+ Assert.Null(await Stored());
+
+ // attributed to someone of another server, it is still refused
+ var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B);
+ var claimed = Event("Someone else's");
+ claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}";
+ claimed["attributedTo"] = elsewhere.Id;
+ Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
+ }
+
[Fact]
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
{
diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs
index 8794393..b60a455 100644
--- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs
+++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs
@@ -171,12 +171,19 @@ namespace PrivaPub.Tests.Federation
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
- var victim = new RemoteActor(_peer, "victim");
+ var victim = new RemoteActor(_peer, "victim", _peer.B);
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
Assert.Equal(400, result.StatusCode);
Assert.False(await DB.Default.Find().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
+
+ // a colleague on mallory's own server is that server's to vouch for: believed only as the server has it, and
+ // it has no such note
+ var colleague = new RemoteActor(_peer, "colleague");
+ result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: colleague.Id));
+ Assert.Equal(202, result.StatusCode);
+ Assert.False(await DB.Default.Find().Match(p => p.ActorURI == colleague.Id).ExecuteAnyAsync(token));
}
[Fact]
diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs
index 0ca7687..8e92031 100644
--- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs
+++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs
@@ -72,6 +72,23 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Drop("fetch-failed");
}
var note = NoteParser.Parse(node);
+ // made by another account of the author's own server (Mobilizon's organiser creates the group's event): taken
+ // as that server has it, under the account it is attributed to
+ if (note != default && note.AttributedTo != author.ActorURI && Origin.Same(note.AttributedTo, author.ActorURI))
+ {
+ if (!refetched)
+ {
+ using var fetched = await _remoteActors.FetchObject(note.Id, token);
+ note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
+ refetched = true;
+ }
+ author = note == default ? default : await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
+ if (author == default)
+ {
+ Arrival.Drop(note == default ? "fetch-failed" : "author-unavailable");
+ return;
+ }
+ }
if (note == default || note.AttributedTo != author.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs
index 96292d2..6b041e5 100644
--- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs
+++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs
@@ -77,6 +77,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
await RemoteDeletes.Tombstone(objectUri, token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
+ // deleted by another account of its author's server (Mobilizon's organiser deletes the group's event): once
+ // that server says it is gone
+ if (post == default && await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token) is { } held
+ && held.ActorURI != actor.ActorURI && Origin.Same(held.ActorURI, actor.ActorURI) && await _remoteActors.IsGone(objectUri, token))
+ post = held;
if (post == default)
{
Arrival.Accept("tombstone-only");
diff --git a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs
index 871faaf..1e6540a 100644
--- a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs
+++ b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs
@@ -55,6 +55,25 @@ namespace PrivaPub.Federation.Inbox.Handlers
return;
}
var note = NoteParser.Parse(inner);
+ // edited by another account of the author's own server (Mobilizon's organiser edits the group's event): applied
+ // as that server has it now
+ if (note != default && note.AttributedTo != actor.ActorURI && Origin.Same(note.AttributedTo, actor.ActorURI))
+ {
+ using var fetched = await _remoteActors.FetchObject(note.Id, token);
+ var current = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
+ if (current == default || current.Id != note.Id || current.AttributedTo != note.AttributedTo)
+ {
+ Arrival.Drop("fetch-failed");
+ return;
+ }
+ note = current;
+ actor = await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
+ if (actor == default)
+ {
+ Arrival.Drop("author-unavailable");
+ return;
+ }
+ }
if (note == default || note.AttributedTo != actor.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs
index 30c76f5..3811105 100644
--- a/PrivaPub/Federation/Inbox/InboxReceiver.cs
+++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs
@@ -235,8 +235,10 @@ namespace PrivaPub.Federation.Inbox
break;
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri:
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities", Reason: "undo-foreign");
+ // (attributed to another account of the actor's own server, as Mobilizon's organiser creates a group's
+ // event: the handlers read it from that server, Origin.Same)
case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri)
- && inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri
+ && inner["attributedTo"] != default && !Origin.Same(Id(inner["attributedTo"]), actorUri)
&& Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"):
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor", Reason: "misattributed");
}