Deliveries to followers carry a digest of them, per server

Owner decision of 2026-10-06 (FEP-8fcf). A persona's delivery addressed to its followers carries a signed
Collection-Synchronization header naming its followers, its roll-call (…/groupies/roll-call) and the digest of its
accepted followers on the receiving server only. The roll-call answers a signed request with the persona's followers
on the signer's server and nobody else's.

Mastodon gives every Undo{Follow} it sends after reading a roll-call the same id (…#follows//undo), so a second one
looked like a copy: an Undo of a Follow that comes again while the follow it ends exists again is now kept once per
follow.

Checked live (scenarios/followsync.sh): Mastodon drops a follow PrivaPub lost, and undoes one it lost itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 07:37:03 +02:00
1 parent bf37223849
commit bd18de35a6
10 files changed
+385 -4

No files matched your search

@@ -0,0 +1,70 @@
using MongoDB.Bson;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace PrivaPub.Federation.Actors
{
// FEP-8fcf (owner decision 2026-10-06): a delivery addressed to a persona's followers carries a digest of its followers on
// the receiving server, and that server reads which of its own accounts they are at the persona's roll-call, signed. A
// server whose view differs (a Follow or an Undo lost on the way) mends it from there. No server learns of followers
// anywhere else.
public static class FollowersSynchronization
{
public const string Header = "Collection-Synchronization";
public static bool Synchronizes(LocalActor actor) => actor is { Kind: LocalActorKind.Person, IsCircle: false };
public static string RollCall(LocalActor actor) => actor.Followers + "/roll-call";
// a server, as Mastodon cuts a URL to tell where its accounts live: scheme and authority
public static string Origin(string uri) =>
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && parsed.Scheme is "https" or "http" ? $"{parsed.Scheme}://{parsed.Authority}" : default;
// the actor's accepted followers whose ids live under origin
public static async Task<List<string>> On(LocalActor actor, string origin, CancellationToken token)
{
if (origin == default)
return [];
var under = new BsonRegularExpression("^" + Regex.Escape(origin + "/"));
return await DB.Default.Find<Follower, string>()
.Match(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted)
.Match(f => f.Regex(x => x.ActorURI, under))
.Project(f => f.ActorURI)
.ExecuteAsync(token);
}
// the XOR of each id's SHA-256, in lower-case hex: the same set gives the same digest in any order
public static string Digest(IEnumerable<string> ids)
{
var digest = new byte[SHA256.HashSizeInBytes];
foreach (var id in ids)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(id));
for (var i = 0; i < digest.Length; i++)
digest[i] ^= hash[i];
}
return Convert.ToHexStringLower(digest);
}
public static string HeaderValue(LocalActor actor, string digest) =>
$"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\"";
// whether the activity, or the object it carries, is addressed to the actor's followers
public static bool ForFollowers(JsonObject activity, LocalActor actor) =>
Addressed(activity, actor.Followers) || activity["object"] is JsonObject inner && Addressed(inner, actor.Followers);
static bool Addressed(JsonObject node, string followers) =>
new[] { "to", "cc", "bto", "bcc" }.Any(field => node[field] switch
{
JsonArray many => many.Any(a => a is JsonValue value && value.TryGetValue<string>(out var text) && text == followers),
JsonValue one => one.TryGetValue<string>(out var text) && text == followers,
_ => false
});
}
}