diff --git a/CLAUDE.md b/CLAUDE.md index ef3a251..8ad9ce8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -591,7 +591,8 @@ tools/pasture/run.sh down # removes e `peers/aoderelay.sh` aode-relay 0.3.129 as `aoderelay.test`):** `appsettings.Pasture.json` names both in `Federation:Relays`, so PrivaPub subscribes a minute after it starts. `scenarios/relay.sh` has Mastodon subscribe to each in turn, checks that a post of an account nobody here follows reaches the federated timeline (forwarded by one, - announced by the other), and has Mastodon leave again, so the town sees no relayed posts. 13 checks. + announced by the other), that alice's public post goes to the relays (and through aode-relay to Mastodon) and nothing + less public, and has Mastodon leave again, so the town sees no relayed posts. 15 checks. - **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit), with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with diff --git a/FEDERATION.md b/FEDERATION.md index 46f5e96..03759a8 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -265,8 +265,11 @@ Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it -announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay, and -nothing of a persona's is sent to one: sending public posts to relays waits for the owner. +announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A +persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision +2026-10-06), as Mastodon sends them; nothing less public, and no boost. Mastodon takes a post Activity-Relay forwards +only with an LD signature or an FEP-8b32 proof, which PrivaPub does not yet add, so it reaches Mastodon through relays +that announce (aode-relay). ## Server descriptions and the crawler diff --git a/PrivaPub.Tests/Federation/RelayTests.cs b/PrivaPub.Tests/Federation/RelayTests.cs index d3bfc39..81c4026 100644 --- a/PrivaPub.Tests/Federation/RelayTests.cs +++ b/PrivaPub.Tests/Federation/RelayTests.cs @@ -104,6 +104,24 @@ namespace PrivaPub.Tests.Federation Assert.Equal(PostVisibility.Public, kept[0].Visibility); } + // owner decision 2026-10-06: a persona's public post, its edit and its deletion go to the relays too; nothing less + // public does, nor a boost + [Fact] + public async Task A_personas_public_post_goes_to_the_relays_and_nothing_less_public() + { + var relay = await Subscribed(); + var (_, alice) = await _harness.Persona("alice"); + var token = Token; + + var open = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for everyone", Visibility = PostVisibility.Public }, token); + await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "quietly", Visibility = PostVisibility.Unlisted }, token); + await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for followers", Visibility = PostVisibility.FollowersOnly }, token); + + var sent = (await _harness.Outgoing(relay.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Create").ToList(); + var create = Assert.Single(sent); + Assert.Equal(alice.PostUri(open.Post.ID), create["object"]!["id"]!.GetValue()); + } + [Fact] public async Task A_post_a_relay_announces_is_its_authors_never_the_relays_boost() { diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 4d7d2d4..95849d6 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -44,13 +44,14 @@ namespace PrivaPub.Tests.Support Local = new LocalActorService(Db, new StaticOptions(new AppConfiguration { BackendBaseAddress = Base })); Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db); Delivery = new DeliveryService(Db, Queue); + Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery); Fanout = new Fanout(Db); Groups = new GroupDistributor(Delivery); Records = new ObjectRecords(Queue); Polls = new PollService(Db, Local, Delivery, Queue); Reactions = new Reactions(Db, Delivery); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); - Outbox = new OutboxPublisher(Db, Local, Delivery); + Outbox = new OutboxPublisher(Db, Local, Delivery, Relays); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox); Participations = new Participations(Db, Local, Delivery); @@ -58,7 +59,6 @@ namespace PrivaPub.Tests.Support Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Relationships = new RelationshipService(Db, Follows, Delivery); Featured = new FeaturedPosts(Db, Remote, RemotePosts); - Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery); Handlers = new IActivityHandler[] { new FollowHandler(Db, Local, Remote, Delivery), diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs index e95f8a6..3ad1c17 100644 --- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -28,9 +28,11 @@ namespace PrivaPub.Federation.Outbox readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IDeliveryService _delivery; + readonly Relays.IRelays _relays; - public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery) + public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, Relays.IRelays relays = default) { + _relays = relays; _dbEntities = dbEntities; _localActors = localActors; _delivery = delivery; @@ -79,6 +81,12 @@ namespace PrivaPub.Federation.Outbox inboxes.Add(inbox); } + // a persona's own public post, outside any group, also goes to the relays we subscribe to (owner decision + // 2026-10-06), its edits and deletion with it + if (_relays != default && post.Visibility == PostVisibility.Public && author.Kind == LocalActorKind.Person + && string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId)) + inboxes.AddRange(await _relays.Inboxes(token)); + return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); } diff --git a/PrivaPub/Federation/Relays/Relays.cs b/PrivaPub/Federation/Relays/Relays.cs index 6f59447..5ef83de 100644 --- a/PrivaPub/Federation/Relays/Relays.cs +++ b/PrivaPub/Federation/Relays/Relays.cs @@ -21,11 +21,13 @@ namespace PrivaPub.Federation.Relays Task Reconcile(CancellationToken token); Task Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token); Task Passes(string actorUri, CancellationToken token); + Task> Inboxes(CancellationToken token); } // Relays (Activity-Relay, aode-relay, pub-relay): the instance actor follows Public at each relay the configuration // names, as Mastodon subscribes, and takes what they pass on: public posts, forwarded as their authors sent them (read - // again from their origin, as any forwarded post) or announced by the relay. Nothing of a persona's is sent to a relay. + // again from their origin, as any forwarded post) or announced by the relay. A persona's public posts go to the relays + // that accepted us (owner decision 2026-10-06), as Mastodon sends them; nothing else of a persona's does. public class Relays : IRelays { static readonly TimeSpan AskAgain = TimeSpan.FromDays(1); @@ -108,6 +110,11 @@ namespace PrivaPub.Federation.Relays return accepted.Contains(actorUri); } + // the inboxes of the relays that accepted our subscription, where a persona's public posts also go + public async Task> Inboxes(CancellationToken token) => + (await DB.Default.Find().Match(s => s.State == RelayState.Accepted).ExecuteAsync(token)) + .Select(s => s.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); + // the relay's actor, from its address or, for a relay named by its inbox, from the /actor beside it async Task Resolve(string address, CancellationToken token) { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 581005d..78a7c0c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -278,9 +278,14 @@ and circles (see Owner decisions). | An account a persona follows moves | **Move the follow, as Mastodon does.** After a verified `Move` the persona follows the new account (a Follow to its server) and unfollows the old, in the same lists; a mute or a block of the old account carries over. | | 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. | -**Waiting for the owner:** a persona's wall (G-0009). Smithereen sends a post written on someone else's wall only to -servers whose actors publish a wall (`sm:wall`, FEP-400e). Publishing one would also let Smithereen's users write on a -persona's wall, and PrivaPub would then host their posts and announce them with `Add{Note}`. +### Owner decisions on walls, relays, threads and followers (2026-10-06) + +| Question | Decision | +|---|---| +| A persona's wall (G-0009) | **Yes, publish walls.** A persona's actor names its wall (`sm:wall`, FEP-400e); Smithereen's users may write on it, PrivaPub hosts their posts there and tells the persona's followers with `Add{Note}`. | +| Personas' public posts and relays | **Send public posts too.** A persona's public post (not unlisted, not followers-only, not local-only) also goes to the relays PrivaPub subscribes to, as Mastodon sends them. | +| A post's `replies` and `context` | **Publish public threads.** Public and unlisted posts name their `replies` and `context` collections, which list only the public and unlisted replies PrivaPub holds; followers-only, circle, direct and local-only posts never do. | +| FEP-8fcf followers synchronisation | **Send digests.** A delivery to a server that the persona's followers there would get carries a `Collection-Synchronization` header: a digest of the persona's followers on that server only, and where that server reads that partial list. | ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) @@ -682,9 +687,10 @@ it, raw where it doesn't. - FEP-8b32 proof verification; - `hs2019` with SHA-512. - **Discovery:** - - a relay client for both relay styles: **reading done 2026-10-05** (`Federation:Relays`; forwarded posts read again - from their origin, announces unwrapped; checked live against Activity-Relay and aode-relay); sending public posts to - relays waits for the owner; + - a relay client for both relay styles: **done 2026-10-05/06** (`Federation:Relays`; forwarded posts read again + from their origin, announces unwrapped; personas' public posts sent to them, owner decision; checked live against + Activity-Relay and aode-relay). Mastodon drops what Activity-Relay forwards without an LD signature or FEP-8b32 + proof; - instance actor discovery (FEP-d556, FEP-2677); - `implements` (FEP-844e). - **Mastodon API:** ~~streaming WebSocket~~ (done 2026-10-05: `/api/v1/streaming` as a WebSocket and as server-sent diff --git a/tools/pasture/scenarios/relay.sh b/tools/pasture/scenarios/relay.sh index db77aed..bff6c50 100644 --- a/tools/pasture/scenarios/relay.sh +++ b/tools/pasture/scenarios/relay.sh @@ -1,8 +1,8 @@ -# Relays as PrivaPub reads them (Federation:Relays in appsettings.Pasture.json): Activity-Relay, which forwards what its +# Relays as PrivaPub uses them (Federation:Relays in appsettings.Pasture.json): Activity-Relay, which forwards what its # subscribers send, and aode-relay, which announces it. For each, the instance actor subscribes, Mastodon subscribes too, -# and a public post of a Mastodon account nobody here follows reaches PrivaPub's federated timeline through the relay; -# nothing of a persona's goes to a relay. Mastodon leaves each relay after, so the town sees no relayed posts. Needs the -# relay, aoderelay and mastodon peers. +# a public post of a Mastodon account nobody here follows reaches PrivaPub's federated timeline through the relay, and a +# persona's public post goes to the relay (owner decision 2026-10-06), nothing less public. Mastodon leaves each relay +# after, so the town sees no relayed posts. Needs the relay, aoderelay and mastodon peers. M=https://mastodon.test:6443 mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; } . "$here/peers/mastodon.sh" @@ -36,10 +36,12 @@ s_uri=$(relayed_post relay) until_true 60 'p_public_has "$s_uri"' && ok "a public post of an account nobody here follows reaches the federated timeline, forwarded" || ko "the forwarded post never arrived" [ "$(curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(any(s['uri'] == '$s_uri' for s in d))")" = "False" ] \ && ok "and no one's home" || ko "the relayed post landed in alice's home" -p_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post the relay never sees $run&visibility=public" | j "print(d['uri'])") -sleep 5 -[ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Job.countDocuments({Host:"relay.test", Payload:/Create/}))')" = "0" ] \ - && ok "nothing of a persona's goes to the relay" || ko "PrivaPub sent the relay a post" +# relay_creates : the Creates PrivaPub has queued for relay.test naming the text +relay_creates() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Job.countDocuments({Host:"relay.test", Payload:/Create/, Payload:/'"$1"'/}))'; } +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post for the relay $run&visibility=public" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a quiet PrivaPub post $run&visibility=unlisted" +until_true 30 '[ "$(relay_creates "a PrivaPub post for the relay $run")" = "1" ]' && ok "alice's public post goes to the relay" || ko "PrivaPub never sent the relay alice's public post" +[ "$(relay_creates "a quiet PrivaPub post $run")" = "0" ] && ok "and nothing less public" || ko "PrivaPub sent the relay an unlisted post" m_unrelay relay.test until_true 45 '! relay_subscribers | grep -qx mastodon.test' && ok "Mastodon leaves Activity-Relay" || ko "Mastodon is still subscribed to Activity-Relay" @@ -52,5 +54,9 @@ a_uri=$(relayed_post aoderelay) until_true 60 'p_public_has "$a_uri"' && ok "a post aode-relay announces reaches the federated timeline as its author's" || ko "the announced post never arrived" [ "$(curl -s -H "$PH" "$P/api/v1/timelines/public?remote=true&limit=40" | j "print(any((s.get('reblog') or {}).get('uri') == '$a_uri' for s in d))")" = "False" ] \ && ok "never as the relay's boost" || ko "the relay's announce shows as a boost" +# alice's public post reaches Mastodon through aode-relay's announce, though nobody there follows her +a_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post aode-relay brings $run&visibility=public" | j "print(d['uri'])") +until_true 60 '[ "$(podman exec pasture-mastodon bin/rails runner "puts Status.exists?(uri: \"$a_post\")" 2>/dev/null | tail -1)" = "true" ]' \ + && ok "alice's public post reaches Mastodon through aode-relay" || ko "alice's post never reached Mastodon through aode-relay" m_unrelay aoderelay.test until_true 45 '! aoderelay_connected | grep -q mastodon.test' && ok "Mastodon leaves aode-relay" || ko "Mastodon is still subscribed to aode-relay"