Replies to a persona's posts reach its followers; personas join remote events

Two owner decisions of 2026-10-05, recorded in the roadmap.

Replies passed on ("the fediverse is broken without"): a public or unlisted
reply from another server to a persona's public, unlisted or followers-only
post goes on to the persona's followers as its author's server sent it, as
Mastodon forwards it, never to the replier's own server, never for a
local-only or group post; its edit and deletion follow. Only an activity its
own actor delivered is passed on (Arrival.Raw), so nothing forwarded is
forwarded again. The town checks it as relay.reply cells (specs/relay-five:
882 checks pass); Mastodon takes a passed-on activity only with an LD
signature, which GoToSocial and Akkoma don't add, and the checker knows it.

Events: a persona joins another server's event with a Join and leaves it with
a Leave, both to the organiser only, through
POST /api/privapub/v1/statuses/:id/join|leave; the organiser's Accept or
Reject is routed by our join id and shows as privapub.event.participation.
Events by invitation or taken on another site are refused before anything
is sent. Mobilizon's scenario joins and leaves an event (28 checks) and keeps
one for decePubClient's e2e.

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-05 14:55:20 +02:00
1 parent 2b42377b3f
commit 0646de22bd
24 files changed
+676 -26

No files matched your search

+15 -4
View File
@@ -88,7 +88,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject, Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors); Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors);
RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down); RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down);
Forwarded (what a thread's server passes on, believed as far as the origin vouches) Forwarded (what a thread's server passes on, believed as far as the origin vouches);
ReplyRelay (third-party replies to a persona's posts passed on to its followers)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections) Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/ Domain/
@@ -99,7 +100,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it); Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it);
InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered;
Participations: a persona's Join and Leave of a remote event, and the organiser's answer
Api/Mastodon/ Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
@@ -231,6 +233,14 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
fetched as context. Followers-only is detected by the author's stored `followers` URL. fetched as context. Followers-only is detected by the author's stored `followers` URL.
14. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and 14. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone). every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
15. **What PrivaPub passes on is exactly what it received** (owner decision 2026-10-05). A public or unlisted reply from
another server to a persona's public, unlisted or followers-only post, and its Update and Delete, go to the
persona's followers as the activity arrived (`Arrival.Raw`, `Federation/Inbox/ReplyRelay.cs`), signed by the
persona for the transport only: never to the replier's own server, never for a local-only or group post. Only an
activity its own actor delivered carries `Raw`, so a forwarded one is never passed on again.
16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an
interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a
persona's `Join` (`Participations`, by its `/grunts/join-<id>` id), each only from the origin of what it answers.
## Mastodon client API invariants ## Mastodon client API invariants
@@ -552,8 +562,9 @@ tools/pasture/run.sh down # removes e
extension), with the pasture's bundle mounted over certifi's and castore's CA files (hackney trusts only those) and extension), with the pasture's bundle mounted over certifi's and castore's CA files (hackney trusts only those) and
the system store, and geocoding pointed at a closed local port. `mobilizon_ctl users.new` makes mzuser (it splits the system store, and geocoding pointed at a closed local port. `mobilizon_ctl users.new` makes mzuser (it splits
its arguments on spaces); the API is GraphQL (`/api`), signed in by the `login` mutation. An event made through it its arguments on spaces); the API is GraphQL (`/api`), signed in by the `login` mutation. An event made through it
without options has its comments closed. `scenarios/mobilizon.sh`, 23 checks: a group and its events (dates, place), without options has its comments closed. `scenarios/mobilizon.sh`, 27 checks: a group and its events (dates, place),
comments both ways, the organiser's edit, closed comments and deletes, a group post, the unfollow. alice joining an event (Mobilizon's participant row and its Accept) and leaving it, comments both ways, the
organiser's edit, closed comments and deletes, a group post, the unfollow.
- **Gancio (1.28.2):** cisti's image on sqlite in the `pasture-gancio-data` volume, whose `config.json` is written before - **Gancio (1.28.2):** cisti's image on sqlite in the `pasture-gancio-data` volume, whose `config.json` is written before
the first start (without it Gancio waits in its setup wizard), trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`. the first start (without it Gancio waits in its setup wizard), trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`.
`gancio users create` makes gcadmin; `gancio settings set enable_resources true` keeps fediverse replies. One `gancio users create` makes gcadmin; `gancio settings set enable_resources true` keeps fediverse replies. One
+16 -2
View File
@@ -124,6 +124,7 @@ Received:
|---|---| |---|---|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand | | `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested | | `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused |
| `Undo{Follow, Like, Announce}` | reverses it | | `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread | | `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept | | `Update{Note}` | replaces the content; the previous version is kept |
@@ -131,14 +132,15 @@ Received:
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | | `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back | | `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) | | `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | | `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`. `Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the replies to a
persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed. Uploaded files have all metadata removed.
@@ -146,6 +148,18 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll vote
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account. - **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
- **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
`Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public
or unlisted. A server that receives it checks it as it checks any forwarded activity (Mastodon by the LD signature
or by reading the reply from its origin).
- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a
`Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's
server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as
`privapub.event.participation`: `pending`, `accepted` or `rejected`. An event that takes participants by invitation
or on another site (Mobilizon's `joinMode` `invite` or `external`) is refused before anything is sent (422). Who
takes part is public on the event's server.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag` A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags. tags.
@@ -0,0 +1,100 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// a persona joins a remote event (owner decision 2026-10-05): Join, answered Accept or Reject; Leave
[Trait("Category", "Integration")]
public sealed class ParticipationTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Group, Post Event)> Announced(string joinMode = "free")
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var group = new RemoteActor(_harness.Peer, "group", type: "Group");
await Follows(alice.Id, group);
var happening = new JsonObject
{
["id"] = NewId(group, "events"), ["type"] = "Event", ["name"] = "A picnic", ["content"] = "<p>bring bread</p>",
["attributedTo"] = group.Id, ["startTime"] = DateTime.UtcNow.AddDays(3).ToString("O"), ["joinMode"] = joinMode,
["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
};
await _harness.Deliver(group, "/human-centipede", Create(group, happening));
return (alice, group, await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(happening)).ExecuteSingleAsync(token));
}
Task<Participation> Of(PrivaPub.Federation.Actors.LocalActor alice, Post happening) =>
DB.Default.Find<Participation>().Match(p => p.AvatarId == alice.Id && p.PostId == happening.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task A_persona_joins_an_event_its_server_accepts_and_leaves_it()
{
var token = TestContext.Current.CancellationToken;
var (alice, group, happening) = await Announced();
Assert.True((await _harness.Participations.Join(alice, happening.ID, token)).Ok);
var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Join");
Assert.Equal((alice.Uri, happening.ObjectURI), (join["actor"]!.GetValue<string>(), join["object"]!.GetValue<string>()));
Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State);
await _harness.Deliver(group, "/human-centipede", new JsonObject
{
["id"] = NewId(group, "accepts"), ["type"] = "Accept", ["actor"] = group.Id, ["object"] = join["id"]!.GetValue<string>()
});
Assert.Equal(ParticipationState.Accepted, (await Of(alice, happening)).State);
Assert.True((await _harness.Participations.Leave(alice, happening.ID, token)).Ok);
Assert.Null(await Of(alice, happening));
var leave = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Leave");
Assert.Equal(happening.ObjectURI, leave["object"]!.GetValue<string>());
}
[Fact]
public async Task A_refusal_counts_an_answer_from_another_server_does_not_and_an_invitation_only_event_is_not_joined()
{
var token = TestContext.Current.CancellationToken;
var (alice, group, happening) = await Announced();
await _harness.Participations.Join(alice, happening.ID, token);
var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Join");
var stranger = new RemoteActor(_harness.Peer, "stranger", _harness.Peer.B);
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = NewId(stranger, "accepts"), ["type"] = "Accept", ["actor"] = stranger.Id, ["object"] = join["id"]!.GetValue<string>()
});
Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State);
await _harness.Deliver(group, "/human-centipede", new JsonObject
{
["id"] = NewId(group, "rejects"), ["type"] = "Reject", ["actor"] = group.Id,
["object"] = new JsonObject { ["id"] = join["id"]!.GetValue<string>(), ["type"] = "Join", ["actor"] = alice.Uri, ["object"] = happening.ObjectURI }
});
Assert.Equal(ParticipationState.Rejected, (await Of(alice, happening)).State);
var (carol, _, invitationOnly) = await Announced("invite");
Assert.Equal(422, (await _harness.Participations.Join(carol, invitationOnly.ID, token)).Status);
}
}
}
@@ -0,0 +1,114 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// Inbox forwarding of replies (owner decision 2026-10-05): a reply to a persona's post reaches the persona's followers,
// who are not among those its author's server sends it to
[Trait("Category", "Integration")]
public sealed class ReplyRelayTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// alice's post; bob replies from his server, fan follows alice from another, pal from bob's
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, Post Post, RemoteActor Bob, RemoteActor Fan, RemoteActor Pal)> Thread()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var fan = new RemoteActor(_harness.Peer, "fan", _harness.Peer.B);
var pal = new RemoteActor(_harness.Peer, "pal");
await _harness.FollowedBy(alice, fan);
await _harness.FollowedBy(alice, pal);
var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "what do you think?" }, token)).Post;
return (alice, post, bob, fan, pal);
}
JsonObject Reply(RemoteActor bob, Post post, string visibility = "public")
{
var reply = PublicNote(bob, "<p>I think so</p>", post.ActorURI);
reply["inReplyTo"] = post.ObjectURI;
if (visibility == "followers")
{
reply["to"] = new JsonArray(bob.Id + "/followers");
reply["cc"] = new JsonArray(post.ActorURI);
}
return reply;
}
Task<List<JsonObject>> At(RemoteActor follower) => _harness.Outgoing(follower.SharedInbox);
[Fact]
public async Task A_public_reply_to_a_personas_post_reaches_its_followers_as_it_came_and_not_its_authors_server()
{
var (_, post, bob, fan, pal) = await Thread();
var create = Create(bob, Reply(bob, post));
create["signature"] = new JsonObject { ["type"] = "RsaSignature2017", ["signatureValue"] = "kept as it came" };
await _harness.Deliver(bob, "/human-centipede", create);
var relayed = Assert.Single(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(create));
Assert.Equal(bob.Id, relayed["actor"]!.GetValue<string>());
Assert.Equal("kept as it came", relayed["signature"]!["signatureValue"]!.GetValue<string>());
Assert.DoesNotContain(await At(pal), a => a["id"]!.GetValue<string>() == IdOf(create));
}
[Fact]
public async Task Its_edit_and_deletion_follow_it_and_a_followers_only_reply_stays_where_it_was_sent()
{
var (_, post, bob, fan, _) = await Thread();
var reply = Reply(bob, post);
await _harness.Deliver(bob, "/human-centipede", Create(bob, reply));
var edited = (JsonObject)reply.DeepClone();
edited["content"] = "<p>I think so, on second thought</p>";
edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
var update = Activity(bob, "Update", edited);
await _harness.Deliver(bob, "/human-centipede", update);
var delete = Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!);
await _harness.Deliver(bob, "/human-centipede", delete);
var relayed = (await At(fan)).Select(a => a["id"]!.GetValue<string>()).ToList();
Assert.Contains(IdOf(update), relayed);
Assert.Contains(IdOf(delete), relayed);
var quiet = Create(bob, Reply(bob, post, "followers"));
await _harness.Deliver(bob, "/human-centipede", quiet);
Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(quiet));
}
[Fact]
public async Task A_reply_that_reached_us_forwarded_is_not_passed_on_again()
{
var (_, post, bob, fan, _) = await Thread();
var other = new RemoteActor(_harness.Peer, "other", _harness.Peer.B);
var reply = Reply(bob, post);
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString());
var create = Create(bob, reply);
var result = await _harness.Deliver(other, "/human-centipede", create);
Assert.Equal("forwarded", result.Reason);
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(reply)).ExecuteAnyAsync(TestContext.Current.CancellationToken));
Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(create));
}
}
}
+5 -3
View File
@@ -52,12 +52,13 @@ namespace PrivaPub.Tests.Support
Outbox = new OutboxPublisher(Db, Local, Delivery); Outbox = new OutboxPublisher(Db, Local, Delivery);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox); Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox);
Participations = new Participations(Db, Local, Delivery);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Handlers = new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes, Approvals), new AcceptHandler(Db, Local, Quotes, Approvals, Participations),
new RejectHandler(Db, Local, Quotes, Approvals), new RejectHandler(Db, Local, Quotes, Approvals, Participations),
new UndoHandler(Db, Local, Reactions), new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions), new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions),
@@ -67,7 +68,7 @@ namespace PrivaPub.Tests.Support
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local), new FlagHandler(Db, Local),
new BlockHandler(Db, Local) new BlockHandler(Db, Local)
}; };
@@ -108,6 +109,7 @@ namespace PrivaPub.Tests.Support
public Reactions Reactions { get; } public Reactions Reactions { get; }
public QuoteService Quotes { get; } public QuoteService Quotes { get; }
public InteractionApprovals Approvals { get; } public InteractionApprovals Approvals { get; }
public Participations Participations { get; }
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
public RelationshipService Relationships { get; } public RelationshipService Relationships { get; }
public ReportService Reports { get; } public ReportService Reports { get; }
@@ -33,9 +33,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs; readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs;
readonly Domain.Media.IMediaService _media; readonly Domain.Media.IMediaService _media;
readonly IParticipations _participations;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes, public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media) IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default)
{ {
_participations = participations;
_jobs = jobs; _jobs = jobs;
_media = media; _media = media;
_quotes = quotes; _quotes = quotes;
@@ -248,6 +251,22 @@ namespace PrivaPub.Api.Mastodon.Controllers
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty }); return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
} }
// a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands
// (privapub.event.participation)
[HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")]
public async Task<IActionResult> Join(string id, CancellationToken token)
{
var outcome = await _participations.Join(Me, id, token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")]
public async Task<IActionResult> Leave(string id, CancellationToken token)
{
var outcome = await _participations.Leave(Me, id, token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")] [HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token); public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
@@ -148,6 +148,7 @@
public int? Capacity { get; set; } public int? Capacity { get; set; }
public string Status { get; set; } public string Status { get; set; }
public string Category { get; set; } public string Category { get; set; }
public string Participation { get; set; }//the viewer's: "pending", "accepted" or "rejected"
} }
public class PrivaPubPlace public class PrivaPubPlace
@@ -215,6 +215,11 @@ namespace PrivaPub.Api.Mastodon.Mappers
}) })
.OrderByDescending(e => e.Count) .OrderByDescending(e => e.Count)
.ToList()); .ToList());
var eventIds = all.Where(p => p.Event != default && p.IsFederatedCopy).Select(p => p.ID).ToList();
var participations = viewerId == default || eventIds.Count == 0
? new Dictionary<string, Models.Social.ParticipationState>()
: (await DB.Default.Find<Models.Social.Participation>().Match(p => p.AvatarId == viewerId && eventIds.Contains(p.PostId)).ExecuteAsync(token))
.ToDictionary(p => p.PostId, p => p.State);
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList(); var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
var ownVotes = viewerId == default || pollIds.Count == 0 var ownVotes = viewerId == default || pollIds.Count == 0
? new Dictionary<string, List<int>>() ? new Dictionary<string, List<int>>()
@@ -289,6 +294,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
status.QuotesCount = post.QuotesCount; status.QuotesCount = post.QuotesCount;
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId)); status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
status.InteractionPolicy = Policies(post); status.InteractionPolicy = Policies(post);
if (status.Privapub?.Event != default && participations.TryGetValue(post.ID, out var participation))
status.Privapub.Event.Participation = participation.ToString().ToLowerInvariant();
if (post.QuoteState == QuoteState.None) if (post.QuoteState == QuoteState.None)
return; return;
var state = post.QuoteState.ToString().ToLowerInvariant(); var state = post.QuoteState.ToString().ToLowerInvariant();
+131
View File
@@ -0,0 +1,131 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
// A persona's participation in a remote event (owner decision 2026-10-05: personas may join events): a Join with the
// event as its object, sent to the event's server, which answers Accept (Mobilizon takes a free event's participants at
// once) or Reject; a Leave takes it back. A participation is public on the event's server: who comes is shown there.
public interface IParticipations
{
Task<StatusOutcome> Join(LocalActor me, string postId, CancellationToken token);
Task<StatusOutcome> Leave(LocalActor me, string postId, CancellationToken token);
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
}
public class Participations : IParticipations
{
const string JoinPrefix = "join-";
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public Participations(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
async Task<PostEntity> Event(LocalActor me, string postId, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post is { IsFederatedCopy: true, Event: not null } && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default;
}
public async Task<StatusOutcome> Join(LocalActor me, string postId, CancellationToken token)
{
var post = await Event(me, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
// an event by invitation, or one that takes its participants on another site, is not joined from here
if (post.Event.JoinMode is "invite" or "external")
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This event does not take participants this way");
if (await DB.Default.Find<Participation>().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token) is { State: not ParticipationState.Rejected })
return new StatusOutcome(post);
await DB.Default.DeleteAsync<Participation>(p => p.AvatarId == me.Id && p.PostId == post.ID);
var participation = new Participation { ID = ObjectId.GenerateNewId().ToString(), AvatarId = me.Id, PostId = post.ID };
participation.ActivityURI = me.ActivityUri(JoinPrefix + participation.ID);
try
{
await DB.Default.SaveAsync(participation, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new StatusOutcome(post);
}
await Send(me, post, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = participation.ActivityURI,
["type"] = "Join",
["actor"] = me.Uri,
["object"] = post.ObjectURI,
["to"] = new JsonArray(post.ActorURI)
}, token);
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Leave(LocalActor me, string postId, CancellationToken token)
{
var post = await Event(me, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var participation = await DB.Default.Find<Participation>().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token);
if (participation == default || (await DB.Default.DeleteAsync<Participation>(participation.ID)).DeletedCount == 0)
return new StatusOutcome(post);
await Send(me, post, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri("leave-" + participation.ID),
["type"] = "Leave",
["actor"] = me.Uri,
["object"] = post.ObjectURI,
["to"] = new JsonArray(post.ActorURI)
}, token);
return new StatusOutcome(post);
}
async Task Send(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token)
{
var author = string.IsNullOrEmpty(post.AuthorAccountId) ? default : await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token);
var inbox = !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.InboxURL;
if (!string.IsNullOrEmpty(inbox))
await _delivery.Enqueue(me, new[] { inbox }, activity, token);
}
// the event's server answering our Join: Accept or Reject, naming it or carrying it
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
{
var joinId = Id(answer["object"]);
var marker = joinId?.LastIndexOf("/grunts/" + JoinPrefix, StringComparison.Ordinal) ?? -1;
if (marker < 0 || !joinId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return false;
var participation = await DB.Default.Find<Participation>().Match(p => p.ActivityURI == joinId).ExecuteFirstAsync(token);
var post = participation == default ? default : await _dbEntities.Posts.MatchID(participation.PostId).ExecuteFirstAsync(token);
if (post == default || !Origin.Same(post.ObjectURI, actor.ActorURI))
return true;
await DB.Default.Update<Participation>().MatchID(participation.ID)
.Modify(p => p.State, accepted ? ParticipationState.Accepted : ParticipationState.Rejected)
.ExecuteAsync(token);
return true;
}
}
}
+2 -1
View File
@@ -5,8 +5,9 @@ using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
// Raw: the activity as it came, when its own author sent it (ReplyRelay passes it on as it is)
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm, public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm,
IReadOnlyList<string> SignedHeaders, DateTime ReceivedAt, string Context = default) IReadOnlyList<string> SignedHeaders, DateTime ReceivedAt, string Context = default, string Raw = default)
{ {
static readonly AsyncLocal<Arrival> current = new(); static readonly AsyncLocal<Arrival> current = new();
@@ -18,9 +18,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IQuoteService _quotes; readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals; readonly IInteractionApprovals _approvals;
readonly IParticipations _participations;
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default) public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default,
IParticipations participations = default)
{ {
_participations = participations;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_quotes = quotes; _quotes = quotes;
@@ -41,6 +44,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Accept("interaction-answer"); Arrival.Accept("interaction-answer");
return; return;
} }
if (_participations != default && await _participations.Answered(activity, actor, accepted: Type == "Accept", token))
{
Arrival.Accept("participation-answer");
return;
}
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default) if (following == default)
{ {
@@ -79,8 +87,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
public class RejectHandler : AcceptHandler public class RejectHandler : AcceptHandler
{ {
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default) public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default,
: base(dbEntities, localActors, quotes, approvals) IParticipations participations = default)
: base(dbEntities, localActors, quotes, approvals, participations)
{ {
} }
@@ -214,6 +214,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (parent != default && Domain.Privacy.Counted.Reply(post)) if (parent != default && Domain.Privacy.Counted.Reply(post))
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token);
if (conversation != default) if (conversation != default)
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token); await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
if (group is { IsCircle: false }) if (group is { IsCircle: false })
@@ -99,6 +99,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
Arrival.Accept("removed"); Arrival.Accept("removed");
Arrival.About(post.ObjectType, post.Visibility, post.CreationDate); Arrival.About(post.ObjectType, post.Visibility, post.CreationDate);
await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token);
await RemoteDeletes.Remove(post, objectUri, token); await RemoteDeletes.Remove(post, objectUri, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
@@ -25,10 +25,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly IQuoteService _quotes; readonly IQuoteService _quotes;
readonly IDeliveryService _delivery;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups, public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
IObjectRecords records, IQuoteService quotes) IObjectRecords records, IQuoteService quotes, IDeliveryService delivery = default)
{ {
_delivery = delivery;
_quotes = quotes; _quotes = quotes;
_records = records; _records = records;
_groups = groups; _groups = groups;
@@ -90,6 +92,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
var edited = await RemoteEdits.Apply(post, note, Id(activity), _localActors, _records, _quotes, token); var edited = await RemoteEdits.Apply(post, note, Id(activity), _localActors, _records, _quotes, token);
Arrival.Accept(edited ? "edit" : "refresh"); Arrival.Accept(edited ? "edit" : "refresh");
if (edited)
await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token);
if (edited && !string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) if (edited && !string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
} }
+2 -1
View File
@@ -73,7 +73,8 @@ namespace PrivaPub.Federation.Inbox
} }
var arrival = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm, var arrival = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm,
payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString()); payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString(),
payload.ForwardedBy == default ? payload.Activity : default);
Arrival.Current = arrival; Arrival.Current = arrival;
try try
{ {
+43
View File
@@ -0,0 +1,43 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
// ActivityPub's inbox forwarding of replies (owner decision 2026-10-05): a public or unlisted reply to one of our
// personas' own posts reaches the persona's followers as its author sent it, and so do its edits and its deletion, as
// Mastodon forwards the replies to its accounts' posts. The author's server sends a reply to its own followers and to
// the persona, never to the persona's followers, who would otherwise see half of the thread. It goes as it came (an
// LD signature in it stays good), signed by the persona; never to the replier's own server, never once more when it
// reached us forwarded, and never for a followers-only reply or a DM, which were not for the persona's followers.
public static class ReplyRelay
{
public static async Task Pass(PostEntity reply, DbEntities db, ILocalActorService localActors, IDeliveryService delivery, CancellationToken token)
{
var raw = Arrival.Current?.Raw;
if (raw == default || delivery == default || localActors == default || reply is not { IsFederatedCopy: true }
|| reply.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || string.IsNullOrEmpty(reply.AnsweringToPostId))
return;
var parent = await db.Posts.MatchID(reply.AnsweringToPostId).ExecuteFirstAsync(token);
if (parent is not { IsFederatedCopy: false, IsLocalOnly: false } || !string.IsNullOrEmpty(parent.GroupId)
|| parent.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly))
return;
var owner = await localActors.FindById(LocalActorKind.Person, parent.GroupUserId, token);
if (owner is not { IsFederated: true } || JsonNode.Parse(raw) is not JsonObject activity)
return;
var replier = Interactions.HostOf(reply.ActorURI);
var inboxes = (await delivery.FollowerInboxes(owner, token)).Where(i => Interactions.HostOf(i) != replier).ToList();
if (inboxes.Count > 0)
await delivery.Enqueue(owner, inboxes, activity, token);
}
}
}
+6
View File
@@ -93,6 +93,12 @@ namespace PrivaPub.Infrastructure.Data
.Option(o => o.Unique = true) .Option(o => o.Unique = true)
.CreateAsync(token); .CreateAsync(token);
await Plain<Favourite>(token, f => f.ActivityURI); await Plain<Favourite>(token, f => f.ActivityURI);
await DB.Default.Index<Participation>()
.Key(p => p.AvatarId, KeyType.Ascending)
.Key(p => p.PostId, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<Participation>(token, p => p.ActivityURI);
await DB.Default.Index<Marker>() await DB.Default.Index<Marker>()
.Key(m => m.AvatarId, KeyType.Ascending) .Key(m => m.AvatarId, KeyType.Ascending)
.Key(m => m.Timeline, KeyType.Ascending) .Key(m => m.Timeline, KeyType.Ascending)
@@ -102,6 +102,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IPollService, PollService>() .AddSingleton<IPollService, PollService>()
.AddSingleton<IQuoteService, QuoteService>() .AddSingleton<IQuoteService, QuoteService>()
.AddSingleton<IInteractionApprovals, InteractionApprovals>() .AddSingleton<IInteractionApprovals, InteractionApprovals>()
.AddSingleton<IParticipations, Participations>()
.AddSingleton<IJobHandler, PollRefreshJob>() .AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, RecoveryJob>() .AddSingleton<IJobHandler, RecoveryJob>()
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>() .AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
+22
View File
@@ -0,0 +1,22 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
// a persona's answer to a remote event (owner decision 2026-10-05): a Join its organiser has taken, refused or not
// yet answered; leaving deletes it
public class Participation : Entity
{
public string AvatarId { get; set; }
public string PostId { get; set; }//the event
public ParticipationState State { get; set; }
public string ActivityURI { get; set; }//our Join
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum ParticipationState
{
Pending,
Accepted,
Rejected
}
}
+8 -5
View File
@@ -794,8 +794,10 @@ FEP-8a8e (draft) is the common reference.
- Other: `category`, `contacts`. - Other: `category`, `contacts`.
- Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`. - Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`.
- The event is attributed to the Group. - The event is attributed to the Group.
- **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`; - **RSVP:** `Join{object: event}` with a stable id; Mobilizon answers `Accept` or `Reject` naming it; `Leave`. A
`Leave`. persona joins and leaves since 2026-10-05 (owner decision): its `Join` goes to the organiser alone, and Mobilizon
takes a participant of an open event at once (`role` `participant`) and answers `Accept`. It never fetches the
`Join`, so PrivaPub does not serve it. `joinMode` `invite` and `external` are refused before anything is sent.
- The organiser (`actor` on the event) sends its Create, Update and Delete; the event is attributed to the group, - The organiser (`actor` on the event) sends its Create, Update and Delete; the event is attributed to the group,
which announces the Event itself, not the activity. PrivaPub refused the organiser's activities as misattributed which announces the Event itself, not the activity. PrivaPub refused the organiser's activities as misattributed
until 2026-10-05, so an edit was lost and a deletion left the event in place; it now takes them as Mobilizon has until 2026-10-05, so an edit was lost and a deletion left the event in place; it now takes them as Mobilizon has
@@ -805,10 +807,11 @@ FEP-8a8e (draft) is the common reference.
- Its NodeInfo names the software "Mobilizon" (NodeInfo wants lower case) and sends - Its NodeInfo names the software "Mobilizon" (NodeInfo wants lower case) and sends
`Content-Type: application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse: PrivaPub reads `Content-Type: application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse: PrivaPub reads
the raw media type, and lowercases software names. the raw media type, and lowercases software names.
- **Pasture evidence (2026-10-05, Mobilizon 5.2.4, `tools/pasture/scenarios/mobilizon.sh`):** 23 checks pass: - **Pasture evidence (2026-10-05, Mobilizon 5.2.4, `tools/pasture/scenarios/mobilizon.sh`):** 27 checks pass:
alice follows a group; the event its organiser makes arrives as an Event with its start, end and located place; alice follows a group; the event its organiser makes arrives as an Event with its start, end and located place;
comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a reply) and deletes of a she joins it (a participant row on Mobilizon, its `Accept` back as `privapub.event.participation: accepted`) and
comment and the event; a group post with its title; the unfollow; statistics. No RSVP yet. leaves it (the row gone); comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a
reply) and deletes of a comment and the event; a group post with its title; the unfollow; statistics.
- **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP. - **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP.
- **Pasture evidence (2026-10-05, Gancio 1.28.2, `tools/pasture/scenarios/gancio.sh`):** 17 checks pass: alice follows - **Pasture evidence (2026-10-05, Gancio 1.28.2, `tools/pasture/scenarios/gancio.sh`):** 17 checks pass: alice follows
its actor `relay`; a published event arrives as an Event with its start, end and place; her reply is kept as one of its actor `relay`; a published event arrives as an Event with its start, end and place; her reply is kept as one of
+11 -1
View File
@@ -67,6 +67,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
RFC 9421 signatures verified; forwarded activities taken as their origin vouches; an id a sender reuses is no copy; RFC 9421 signatures verified; forwarded activities taken as their origin vouches; an id a sender reuses is no copy;
a follow sent again under a new id (Lemmy); a group's event its organiser edits; NodeInfo read as Mobilizon and a follow sent again under a new id (Lemmy); a group's event its organiser edits; NodeInfo read as Mobilizon and
Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads. Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads.
- GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a
persona's posts passed on to its followers (owner decisions 2026-10-05).
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
- [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
@@ -254,6 +256,13 @@ and circles (see Owner decisions).
| How work lands | Commits on master in both repositories; nothing is pushed, tagged or deployed without asking. | | How work lands | Commits on master in both repositories; nothing is pushed, tagged or deployed without asking. |
| Still asked first | Anything new that tells another server something about a persona, Web Push (traffic to push services), any admin endpoint in production, and anything acting from production or the owner's accounts. | | Still asked first | Anything new that tells another server something about a persona, Web Push (traffic to push services), any admin endpoint in production, and anything acting from production or the owner's accounts. |
### Owner decisions on events and threads (2026-10-05)
| Question | Decision |
|---|---|
| May a persona join a remote event? | **Yes.** A persona joins and leaves another server's event (`Join`/`Leave`, sent to the event's organiser only). Its participation is public on that server, as on every platform with events. Events that take participants only by invitation or on another site are refused before anything is sent. |
| Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
| Area | Choice | | Area | Choice |
@@ -640,7 +649,8 @@ it, raw where it doesn't.
- `indexable`/`discoverable`/`searchableBy`; - `indexable`/`discoverable`/`searchableBy`;
- edit history from `formerRepresentations`; - edit history from `formerRepresentations`;
- PeerTube reply rules and `ApproveReply`. - PeerTube reply rules and `ApproveReply`.
- **Events:** structured RSVP (`Join`/`Leave` with stable ids). - **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live
against Mobilizon.
#### P8 Signatures, discovery and the long tail #### P8 Signatures, discovery and the long tail
- **Signatures:** - **Signatures:**
+23 -2
View File
@@ -1,6 +1,6 @@
# Mobilizon 5.2.4: a group and its events. alice_mobilizon follows the group, which announces the events its members # Mobilizon 5.2.4: a group and its events. alice_mobilizon follows the group, which announces the events its members
# organise; an event arrives as an Event with its dates and place; comments both ways; an edit and comments closed; # organise; an event arrives as an Event with its dates and place; she joins it and leaves; comments both ways; an edit
# deletes; a group post; the unfollow. An event made through its API without options has its comments closed # and comments closed; deletes; a group post; an event kept for decePubClient's e2e; the unfollow. An event made through its API without options has its comments closed
# (`commentsEnabled: false`), which PrivaPub honours, so this one opens them. Mobilizon's API is GraphQL (/api), signed in by its login mutation; what it holds is read from # (`commentsEnabled: false`), which PrivaPub honours, so this one opens them. Mobilizon's API is GraphQL (/api), signed in by its login mutation; what it holds is read from
# its PostGIS (database mobilizon). # its PostGIS (database mobilizon).
MZ=https://mobilizon.test:6443 MZ=https://mobilizon.test:6443
@@ -51,6 +51,17 @@ event_json=$(curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p")
echo "$event_json" | j "e = (d.get('privapub') or {}).get('event') or {}; print(any('Borghese' in ((p.get('name') or '') + (p.get('address') or '')) and p.get('latitude') for p in e.get('places', [])))" | grep -q True \ echo "$event_json" | j "e = (d.get('privapub') or {}).get('event') or {}; print(any('Borghese' in ((p.get('name') or '') + (p.get('address') or '')) and p.get('latitude') for p in e.get('places', [])))" | grep -q True \
&& ok "and with its place, located" || ko "the event's place is missing" && ok "and with its place, located" || ko "the event's place is missing"
echo " participation"
# alice joins the event (owner decision 2026-10-05): a Join Mobilizon takes at once for a free event, then a Leave
participation() { curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p" | j "print(((d.get('privapub') or {}).get('event') or {}).get('participation'))"; }
mz_participant() { mz_sql "select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id where a.url like '%alice_mobilizon%' and e.id = $event_id"; }
curl -s -o /dev/null -X POST -H "$PH" "$P/api/privapub/v1/statuses/$e_on_p/join"
until_true 45 '[ "$(mz_participant)" = "participant" ]' && ok "alice_mobilizon's Join makes her a participant on Mobilizon" || ko "the Join never made a participant ($(mz_participant))"
until_true 45 '[ "$(participation)" = "accepted" ]' && ok "Mobilizon's Accept reaches PrivaPub" || ko "the participation stays $(participation)"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/privapub/v1/statuses/$e_on_p/leave"
until_true 45 '[ -z "$(mz_participant)" ]' && ok "her Leave takes her off the event on Mobilizon" || ko "she is still a participant after leaving"
[ "$(participation)" = "None" ] && ok "and PrivaPub forgets it" || ko "PrivaPub still shows a participation"
echo " comments" echo " comments"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub comment on the picnic&in_reply_to_id=$e_on_p&visibility=public" curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub comment on the picnic&in_reply_to_id=$e_on_p&visibility=public"
until_true 45 '[ "$(mz_sql "select count(*) from comments where text like '"'%a PrivaPub comment on the picnic%'"' and deleted_at is null")" -ge 1 ]' \ until_true 45 '[ "$(mz_sql "select count(*) from comments where text like '"'%a PrivaPub comment on the picnic%'"' and deleted_at is null")" -ge 1 ]' \
@@ -80,6 +91,16 @@ mz 'mutation($g:ID!,$t:String!){createPost(attributedToId:$g,title:$t,body:"<p>t
"{\"g\":\"$group\",\"t\":\"$post_title\"}" >/dev/null "{\"g\":\"$group\",\"t\":\"$post_title\"}" >/dev/null
until_true 60 '[ -n "$(p_home_has "$post_title")" ]' && ok "the group's post reaches alice_mobilizon's home, with its title" || ko "the group's post never arrived" until_true 60 '[ -n "$(p_home_has "$post_title")" ]' && ok "the group's post reaches alice_mobilizon's home, with its title" || ko "the group's post never arrived"
echo " an event kept for decePub's tests"
# decePubClient's e2e joins it from its card (tests/e2e/Actions/EventTests.cs); the one a run before this one kept goes
kept="$here/.state/mobilizon-kept-event.json"
[ -s "$kept" ] && mz 'mutation($e:ID!){deleteEvent(eventId:$e){id}}' "{\"e\":\"$(j "print(d['id'])" < "$kept")\"}" >/dev/null
kept_title="A concert kept for decePub $(date +%s)"
kept_begins=$(date -u -d '+10 days' +%Y-%m-%dT19:00:00Z); kept_ends=$(date -u -d '+10 days' +%Y-%m-%dT22:00:00Z)
mz 'mutation($t:String!,$b:DateTime!,$e:DateTime!,$o:ID!,$g:ID){createEvent(title:$t,description:"<p>come and listen</p>",beginsOn:$b,endsOn:$e,organizerActorId:$o,attributedToId:$g,visibility:PUBLIC,joinOptions:FREE,options:{commentModeration:ALLOW_ALL},physicalAddress:{description:"Piazza del Popolo",locality:"Roma",country:"Italia",geom:"12.4763;41.9109"}){id url}}' \
"{\"t\":\"$kept_title\",\"b\":\"$kept_begins\",\"e\":\"$kept_ends\",\"o\":\"$me\",\"g\":\"$group\"}" | j "print(json.dumps(d['data']['createEvent']))" > "$kept"
until_true 60 '[ -n "$(p_home_has "$kept_title")" ]' && ok "an event kept for decePub's tests reaches PrivaPub" || ko "the kept event never arrived"
echo " unfollow" echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$group_on_p/unfollow" curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$group_on_p/unfollow"
until_true 45 '[ "$(mz_sql "select count(*) from followers f join actors a on a.id = f.actor_id where a.url like '"'%alice_mobilizon%'"'")" = "0" ]' \ until_true 45 '[ "$(mz_sql "select count(*) from followers f join actors a on a.id = f.actor_id where a.url like '"'%alice_mobilizon%'"'")" = "0" ]' \
+30 -2
View File
@@ -3,7 +3,7 @@ one does hold (its database) and show (its API, as one account). Writes out/<run
Each check is a cell `feature|origin|observer|direction`: Each check is a cell `feature|origin|observer|direction`:
in a peer's object as PrivaPub holds it out PrivaPub's object as a peer holds it in a peer's object as PrivaPub holds it out PrivaPub's object as a peer holds it
via a peer's object on another peer, through PrivaPub (a community it hosts) via a peer's object on another peer, through PrivaPub (a community it hosts, or a reply to a persona it relays)
control a peer's object on another peer, PrivaPub not involved (tells a PrivaPub bug from a peer quirk) control a peer's object on another peer, PrivaPub not involved (tells a PrivaPub bug from a peer quirk)
local an object on its own server local an object on its own server
Known gaps (gaps.json) turn a failure into xfail, and a pass of a known gap into XPASS.""" Known gaps (gaps.json) turn a failure into xfail, and a pass of a known gap into XPASS."""
@@ -36,6 +36,11 @@ NEEDS_PICTURE = {"pixelfed"}
# posts above it, and a Friendica account's non-public reply in a thread another server owns reaches none of the # posts above it, and a Friendica account's non-public reply in a thread another server owns reaches none of the
# others there (nobody relays a followers-only reply), nor anything under it # others there (nobody relays a followers-only reply), nor anything under it
THREAD_BOUND = {"friendica"} THREAD_BOUND = {"friendica"}
# Mastodon takes an activity another server passes on only with its author's LD signature (or a FEP-8b32 proof) and
# drops it otherwise (ActivityPub::ProcessActivityService); of the platforms here only Mastodon and Misskey and its forks
# sign their Creates that way, so another's reply PrivaPub passes on never lands there
NEEDS_LD_SIGNATURE = {"mastodon"}
LD_SIGNS = {"mastodon", "misskey", "sharkey"}
class World: class World:
@@ -179,10 +184,11 @@ class Sweep:
stored = self.stored_all() stored = self.stored_all()
self.checks = [] self.checks = []
self.delivery(stored) self.delivery(stored)
self.relayed(stored)
self.counts(stored) self.counts(stored)
self.threads(stored) self.threads(stored)
self.lifecycle(stored) self.lifecycle(stored)
failing = [c for c in self.checks if not c["ok"] and c["feature"].startswith(("deliver", "count", "edit", "delete", "thread"))] failing = [c for c in self.checks if not c["ok"] and c["feature"].startswith(("deliver", "relay", "count", "edit", "delete", "thread"))]
if not failing or time.time() - started > deadline: if not failing or time.time() - started > deadline:
break break
print(f" {len(failing)} checks still failing; sweeping again in 20s", flush=True) print(f" {len(failing)} checks still failing; sweeping again in 20s", flush=True)
@@ -225,6 +231,28 @@ class Sweep:
elif kind in ("followers", "direct", "circle", "located") and p not in want: elif kind in ("followers", "direct", "circle", "located") and p not in want:
self.add(f"confine.{kind}", o["origin"], p, not held, ref, "absent", "held" if held else "absent", through) self.add(f"confine.{kind}", o["origin"], p, not held, ref, "absent", "held" if held else "absent", through)
# -- a peer's public or unlisted reply to a persona's post, which PrivaPub passes on to the persona's followers (owner
# decision 2026-10-05): held on every server where a follower of the persona lives and that the reply was not
# addressed to already
def relayed(self, stored):
for ref, o in self.w.objects.items():
parent = self.w.objects.get(o.get("parent") or "")
if ref in self.w.deleted or parent is None or parent["ref"] in self.w.deleted or o["origin"] == "privapub" \
or parent["origin"] != "privapub" or parent.get("group") or o["visibility"] not in ("public", "unlisted") \
or parent["visibility"] not in ("public", "unlisted", "followers"):
continue
addressed = {self.w.platform(k) for k in self.w.recipients(o)} | {o["origin"], "privapub"}
for p in sorted({self.w.platform(k) for k in self.w.follows.get(parent["author"], ())} - addressed):
# (a reply the seeder had p fetch proves nothing about passing it on)
if p not in self.platforms or RECEIVES.get(p) is not None or p in o.get("fetched_on", ()) \
or (p in NEEDS_LD_SIGNATURE and o["origin"] not in LD_SIGNS):
continue
if p in NEEDS_PICTURE and not (stored.get(p, {}).get(parent["uri"]) and stored[p][parent["uri"]].exists):
continue
row = stored.get(p, {}).get(o["uri"])
held = bool(row and row.exists and not row.deleted)
self.add("relay.reply", o["origin"], p, held, ref, "held", "held" if held else "missing", True)
# -- counts on the object's own server, and PrivaPub's exact counts # -- counts on the object's own server, and PrivaPub's exact counts
def counts(self, stored): def counts(self, stored):
likes, reacts, boosts, votes = defaultdict(int), defaultdict(lambda: defaultdict(int)), defaultdict(int), defaultdict(lambda: defaultdict(int)) likes, reacts, boosts, votes = defaultdict(int), defaultdict(lambda: defaultdict(int)), defaultdict(int), defaultdict(lambda: defaultdict(int))
+100
View File
@@ -0,0 +1,100 @@
{
"schema": "pasture-town/1",
"name": "relay-five",
"seed": 20261051,
"peers": {
"privapub": {
"roots": 1,
"personas": [
4,
4
],
"circles": 0,
"communities": 0
},
"gts": {
"accounts": 2
},
"mastodon": {
"accounts": 2
},
"akkoma": {
"accounts": 2
},
"misskey": {
"accounts": 2
}
},
"profiles": {
"locked": 0.2,
"bot": 0.0,
"fields": [
0,
2
],
"avatar": 0.9,
"header": 0.3,
"bioWords": [
5,
12
],
"langs": {
"en": 80,
"it": 20
}
},
"graph": {
"follows": [
3,
5
],
"mutual": 0.5,
"pending": 0.0,
"rejected": 0.0
},
"circleMembers": 0,
"content": {
"posts": 36,
"mix": {
"text": 60,
"cw": 10,
"tags": 10,
"mention": 10,
"image": 10
},
"visibility": {
"public": 70,
"unlisted": 15,
"followers": 15
},
"replyRounds": 2,
"replies": 0.6,
"deeperReplies": 0.4
},
"interactions": {
"likes": [
0,
2
],
"boosts": [
0,
1
],
"react": 0.2,
"vote": 0.0,
"bookmark": 0.0
},
"mutations": {
"edit": 0.1,
"delete": 0.05,
"blocks": 0,
"mutes": 0,
"reports": 0
},
"time": {
"roundSettleSeconds": 15,
"graphSettleSeconds": 20,
"settleSeconds": 30,
"deadlineSeconds": 120
}
}