A sender's digest of its followers here is compared and mended
FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, only when the list is the one the digest describes; a request it
lists is taken as accepted; a persona it lists that follows nothing there sends Undo{Follow}, as Mastodon does. Each
claiming delivery is compared once.
Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
bd18de35a6
commit
e0682fa868
11 files changed
+291
-12
No files matched your search
@@ -562,10 +562,12 @@ tools/pasture/run.sh down # removes e
|
||||
- **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post,
|
||||
and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy
|
||||
look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 checks.
|
||||
- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** PrivaPub forgets mastouser's follow of
|
||||
alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then
|
||||
- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** sent: PrivaPub forgets mastouser's follow
|
||||
of alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then
|
||||
Mastodon forgets a follow (rails) and alice's next followers-only post makes it send the `Undo` PrivaPub applies.
|
||||
Follows are checked in Mastodon's database: its API caches relationships for a day. 8 checks.
|
||||
Received: alice and bob follow mastouser; Mastodon forgets alice's follow and mastouser's followers-only post makes
|
||||
PrivaPub end it, then PrivaPub forgets it and the next makes PrivaPub undo it there. Follows are checked in Mastodon's
|
||||
database: its API caches relationships for a day. 15 checks.
|
||||
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
|
||||
and unpins while it follows her, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings
|
||||
its earlier posts (its outbox). 9 checks.
|
||||
|
||||
+5
-1
@@ -72,7 +72,7 @@ covered by unit tests written in their documents' shape.
|
||||
`Like` with content)
|
||||
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
||||
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
||||
(sent; see "Followers synchronisation")
|
||||
(sent and honoured; see "Followers synchronisation")
|
||||
|
||||
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
|
||||
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
|
||||
@@ -179,6 +179,10 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
|
||||
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
|
||||
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
|
||||
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
|
||||
Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas
|
||||
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
|
||||
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
|
||||
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
|
||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
|
||||
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
||||
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
|
||||
|
||||
@@ -10,6 +10,7 @@ using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
@@ -146,6 +147,87 @@ namespace PrivaPub.Tests.Federation
|
||||
|
||||
Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason);
|
||||
}
|
||||
|
||||
// received: bob's delivery claims a digest of his followers here, and lists them at his own synchronisation URL
|
||||
async Task Claimed(RemoteActor bob, string digest, params string[] listed)
|
||||
{
|
||||
var path = new Uri(bob.Id).AbsolutePath + "/followers/sync";
|
||||
_harness.Peer.Serve(path, new JsonObject
|
||||
{
|
||||
["id"] = bob.Id + "/followers/sync", ["type"] = "OrderedCollection",
|
||||
["orderedItems"] = new JsonArray(listed.Select(l => (JsonNode)l).ToArray())
|
||||
}.ToJsonString());
|
||||
var note = $"{bob.Id}/notes/{Guid.NewGuid():N}";
|
||||
var request = bob.Post(Harness.Host, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = note + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"),
|
||||
["object"] = new JsonObject { ["id"] = note, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["content"] = "hi" }
|
||||
});
|
||||
request.Headers[FollowersSynchronization.Header] = $"collectionId=\"{bob.Id}/followers\", url=\"{bob.Id}/followers/sync\", digest=\"{digest}\"";
|
||||
await _harness.Receiver.Receive(request, default, Token);
|
||||
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SynchronizeFollowing && j.State == JobState.Pending && j.Payload.Contains(bob.Id))
|
||||
.ExecuteSingleAsync(Token);
|
||||
var handler = new FollowingSynchronization(_harness.Db, _harness.Local, _harness.Remote, _harness.Follows, _harness.Delivery);
|
||||
Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result);
|
||||
}
|
||||
|
||||
async Task<Following> Follows(LocalActor persona, RemoteActor bob, FollowState state)
|
||||
{
|
||||
var following = new Following
|
||||
{
|
||||
AvatarId = persona.Id, TargetActorURI = bob.Id, TargetInboxURL = bob.Id + "/inbox", State = state, FollowActivityURI = persona.ActivityUri($"follow-{Guid.NewGuid():N}")
|
||||
};
|
||||
await DB.Default.SaveAsync(following, Token);
|
||||
return following;
|
||||
}
|
||||
|
||||
Task<Following> Following(LocalActor persona, RemoteActor bob) =>
|
||||
DB.Default.Find<Following>().Match(f => f.AvatarId == persona.Id && f.TargetActorURI == bob.Id).ExecuteFirstAsync(Token);
|
||||
|
||||
[Fact]
|
||||
public async Task A_claim_that_differs_is_mended_from_the_accounts_own_list()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, carol) = await _harness.Persona("carol");
|
||||
var (_, dave) = await _harness.Persona("dave");
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
await Follows(alice, bob, FollowState.Accepted);
|
||||
await Follows(carol, bob, FollowState.Requested);
|
||||
|
||||
await Claimed(bob, FollowersSynchronization.Digest([carol.Uri, dave.Uri]), carol.Uri, dave.Uri);
|
||||
|
||||
Assert.Null(await Following(alice, bob));
|
||||
Assert.Equal(FollowState.Accepted, (await Following(carol, bob)).State);
|
||||
var undos = (await _harness.Outgoing(bob.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Undo").ToList();
|
||||
Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue<string>()).Order());
|
||||
var daves = Assert.Single(undos, u => u["actor"]!.GetValue<string>() == dave.Uri);
|
||||
Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue<string>(), daves["object"]!["object"]!.GetValue<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_list_the_claim_does_not_describe_ends_no_follow()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
await Follows(alice, bob, FollowState.Accepted);
|
||||
|
||||
await Claimed(bob, FollowersSynchronization.Digest(["https://privapub.test/peasants/someone"]));
|
||||
|
||||
Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_claim_that_agrees_reads_nothing()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
await Follows(alice, bob, FollowState.Accepted);
|
||||
|
||||
await Claimed(bob, FollowersSynchronization.Digest([alice.Uri]));
|
||||
|
||||
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path.EndsWith("/followers/sync"));
|
||||
Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
|
||||
@@ -55,6 +55,23 @@ namespace PrivaPub.Federation.Actors
|
||||
public static string HeaderValue(LocalActor actor, string digest) =>
|
||||
$"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\"";
|
||||
|
||||
// what a Collection-Synchronization header claims, written as a Signature header's parameters; default when it
|
||||
// names no collection, list or digest
|
||||
public static SynchronizationClaim Claim(string actorUri, string header)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var part in (header ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var equals = part.IndexOf('=');
|
||||
if (equals > 0)
|
||||
values[part[..equals].Trim()] = part[(equals + 1)..].Trim().Trim('"');
|
||||
}
|
||||
return values.TryGetValue("collectionId", out var collection) && values.TryGetValue("url", out var url) && values.TryGetValue("digest", out var digest)
|
||||
&& digest.Length == 64 && digest.All(Uri.IsHexDigit)
|
||||
? new SynchronizationClaim(actorUri, collection, url, digest.ToLowerInvariant())
|
||||
: default;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
public sealed record SynchronizationClaim(string Actor, string CollectionId, string Url, string Digest);
|
||||
|
||||
// FEP-8fcf, received: an account elsewhere tells, with its delivery, a digest of its followers here. When the personas
|
||||
// following it here digest otherwise, its partial list (read signed by the instance actor) says who they are: a follow
|
||||
// only PrivaPub remembers ends, a request it answered without our knowing is accepted, and a follow only it remembers
|
||||
// is undone by the persona, as Mastodon does. A follow ends only when the list is the one the digest describes.
|
||||
public class FollowingSynchronization : IJobHandler
|
||||
{
|
||||
const int MaxPages = 10;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IFollowService _follows;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public FollowingSynchronization(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IFollowService follows,
|
||||
IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_follows = follows;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.SynchronizeFollowing;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 3;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
// once for each delivery that claims it, as Mastodon compares each: cheap while the digests agree
|
||||
public static string DedupeKey(SynchronizationClaim claim, string activityId) =>
|
||||
activityId == default ? default : $"followsync|{activityId}|{claim.Digest}";
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var claim = JsonSerializer.Deserialize<SynchronizationClaim>(job.Payload);
|
||||
var actor = claim == default ? default : await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == claim.Actor && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (actor == default || string.IsNullOrEmpty(actor.FollowersURL) || claim.CollectionId != actor.FollowersURL || !Origin.Same(claim.Url, actor.ActorURI))
|
||||
return JobOutcome.Done;
|
||||
|
||||
var followings = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && !f.TargetIsLocal).ExecuteAsync(token);
|
||||
var personas = new Dictionary<string, (LocalActor Persona, Following Following)>(StringComparer.Ordinal);
|
||||
foreach (var following in followings)
|
||||
if (await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token) is { } persona)
|
||||
personas[persona.Uri] = (persona, following);
|
||||
var accepted = personas.Values.Where(p => p.Following.State == FollowState.Accepted).Select(p => p.Persona.Uri).ToList();
|
||||
if (FollowersSynchronization.Digest(accepted) == claim.Digest)
|
||||
return JobOutcome.Done;
|
||||
|
||||
var listed = await Listed(claim.Url, token);
|
||||
if (listed == default)
|
||||
return JobOutcome.Retry("the partial followers collection could not be read");
|
||||
|
||||
var complete = FollowersSynchronization.Digest(listed) == claim.Digest;
|
||||
var named = listed.ToHashSet(StringComparer.Ordinal);
|
||||
if (complete)
|
||||
foreach (var uri in accepted.Where(uri => !named.Contains(uri)))
|
||||
await _follows.UnfollowAs(personas[uri].Persona, actor.ActorURI, token);
|
||||
|
||||
foreach (var uri in named)
|
||||
{
|
||||
if (personas.TryGetValue(uri, out var known))
|
||||
{
|
||||
if (known.Following.State == FollowState.Requested)
|
||||
await DB.Default.Update<Following>().MatchID(known.Following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
continue;
|
||||
}
|
||||
if (await _localActors.FindByUri(uri, token) is not { Kind: LocalActorKind.Person } stranger)
|
||||
continue;
|
||||
// (it has no id for a follow PrivaPub never made; it is undone by actor and object)
|
||||
var undo = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = stranger.ActivityUri($"undo-follow-sync-{Guid.NewGuid():N}"),
|
||||
["type"] = "Undo",
|
||||
["actor"] = stranger.Uri,
|
||||
["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = stranger.Uri, ["object"] = actor.ActorURI }
|
||||
};
|
||||
await _delivery.Enqueue(stranger, new[] { string.IsNullOrEmpty(actor.InboxURL) ? actor.SharedInboxURL : actor.InboxURL }, undo, token);
|
||||
}
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
// the ids the collection lists, its pages followed on its own server; default when it cannot be read
|
||||
async Task<List<string>> Listed(string url, CancellationToken token)
|
||||
{
|
||||
using var scope = HttpScope.For("followers-sync");
|
||||
var items = new List<string>();
|
||||
var next = url;
|
||||
for (var page = 0; page < MaxPages && next != default; page++)
|
||||
{
|
||||
using var fetched = await _remoteActors.FetchObject(next, token);
|
||||
if (fetched == default || fetched.Root.ValueKind != JsonValueKind.Object)
|
||||
return page == 0 ? default : items;
|
||||
var root = fetched.Root;
|
||||
if (page == 0 && !Has(root, "orderedItems") && !Has(root, "items") && Link(root, "first") is { } first)
|
||||
{
|
||||
next = Origin.Same(first, url) ? first : default;
|
||||
continue;
|
||||
}
|
||||
foreach (var name in new[] { "orderedItems", "items" })
|
||||
if (root.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array)
|
||||
items.AddRange(list.EnumerateArray().Select(Id).Where(id => id != default));
|
||||
next = Link(root, "next") is { } after && Origin.Same(after, url) ? after : default;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
static bool Has(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array;
|
||||
|
||||
static string Link(JsonElement node, string name) => node.TryGetProperty(name, out var value) ? Id(value) : default;
|
||||
|
||||
static string Id(JsonElement node) => node.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => node.GetString(),
|
||||
JsonValueKind.Object when node.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String => id.GetString(),
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,9 @@ namespace PrivaPub.Federation.Inbox
|
||||
// once per follow, so a retry still counts as a copy
|
||||
if (!queued && dedupe != default && type == "Undo" && await FollowAgain(activity, actorUri, token) is { } follow)
|
||||
queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}|{follow}", token);
|
||||
// FEP-8fcf: what the sender says of its followers here (FollowingSynchronization)
|
||||
if (queued && forwardedBy == default && FollowersSynchronization.Claim(actorUri, request.Headers[FollowersSynchronization.Header].ToString()) is { } claim)
|
||||
await _queue.Enqueue(JobKind.SynchronizeFollowing, JsonSerializer.Serialize(claim), host, FollowingSynchronization.DedupeKey(claim, activityId), token);
|
||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
||||
return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued");
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
.AddSingleton<IJobHandler, Domain.Statuses.PublishScheduledJob>()
|
||||
.AddSingleton<InstanceDescriber>()
|
||||
|
||||
@@ -34,7 +34,8 @@ namespace PrivaPub.Models.Jobs
|
||||
CountAccount,
|
||||
PublishScheduled,
|
||||
FetchReplies,
|
||||
BackfillOutbox
|
||||
BackfillOutbox,
|
||||
SynchronizeFollowing
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
+1
-1
@@ -1165,7 +1165,7 @@ snapshots; `/api/privapub/v1/cdns` and `/cdns/:domain` group servers by CDN, wee
|
||||
| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 |
|
||||
| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 |
|
||||
| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 |
|
||||
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 |
|
||||
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. **Done 2026-10-06**, both ways, checked live against Mastodon. | P2 |
|
||||
| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 |
|
||||
| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 |
|
||||
| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 |
|
||||
|
||||
+3
-2
@@ -672,8 +672,9 @@ it, raw where it doesn't.
|
||||
- re-run WebFinger on a rename;
|
||||
- inbound `Block`; `Add`/`Remove` of pins: **done 2026-10-05** (both ways, a community's pins too, the `featured`
|
||||
collection read with an account's counts; checked live against Mastodon);
|
||||
- FEP-8fcf followers sync: sending **done 2026-10-06** (owner decision; the digest of a persona's followers on the
|
||||
receiving server, and a signed roll-call listing only them; Mastodon mends both ways from it, checked live);
|
||||
- FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the
|
||||
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
|
||||
mended from its list; checked live against Mastodon, all four ways a follow can be lost);
|
||||
- `indexable`/`discoverable`/`searchableBy`;
|
||||
- edit history from `formerRepresentations`;
|
||||
- PeerTube reply rules and `ApproveReply`.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Followers synchronisation (FEP-8fcf, owner decision 2026-10-06): alice's followers-only post tells Mastodon, in a signed
|
||||
# header, a digest of her followers there; when Mastodon's view differs it reads her roll-call and mends itself. Both ways:
|
||||
# a follow PrivaPub lost (Mastodon drops it), and a follow Mastodon lost (it sends the Undo PrivaPub then applies). The
|
||||
# roll-call lists Mastodon's accounts only, and only to a signed request. Needs the mastodon peer.
|
||||
# Followers synchronisation (FEP-8fcf, owner decision 2026-10-06), both ways. Sent: alice's followers-only post tells
|
||||
# Mastodon, in a signed header, a digest of her followers there; when Mastodon's view differs it reads her roll-call and
|
||||
# mends itself: a follow PrivaPub lost (Mastodon drops it), and a follow Mastodon lost (it sends the Undo PrivaPub
|
||||
# applies). The roll-call answers only a signed request. Received: mastouser's followers-only post tells PrivaPub a digest
|
||||
# of its followers here (alice and bob); PrivaPub reads Mastodon's list and ends a follow Mastodon lost, and undoes one
|
||||
# only Mastodon remembers. Needs the mastodon peer.
|
||||
M=https://mastodon.test:6443
|
||||
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
|
||||
. "$here/peers/mastodon.sh"
|
||||
@@ -49,3 +51,29 @@ m_rails "a = Account.find_local(\"mastouser\"); t = Account.find_by(uri: \"$alic
|
||||
[ "$(m_follows)" = "False" ] && ok "Mastodon forgets the follow" || ko "Mastodon still has the follow"
|
||||
quiet_post "for my followers again $run"
|
||||
until_true 60 '[ "$(alice_followers)" = "0" ]' && ok "Mastodon reads the roll-call and undoes the follow PrivaPub had" || ko "PrivaPub still counts mastouser"
|
||||
|
||||
echo " received: mastouser's followers here"
|
||||
BT=$(privapub_token bob_sync)
|
||||
BH="Authorization: Bearer $BT"
|
||||
m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
p_follows() { curl -s -H "$1" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0]['following'])"; }
|
||||
m_followed_by() { m_rails "puts Follow.exists?(account: Account.find_by(uri: \"${P_BASE:-https://privapub.test}/peasants/$1\"), target_account: Account.find_local(\"mastouser\")) ? \"True\" : \"False\""; }
|
||||
m_quiet() { mcurl -s -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" -d "status=$1&visibility=private"; }
|
||||
for who in "$AH" "$BH"; do
|
||||
[ "$(p_follows "$who")" = "True" ] || curl -s -o /dev/null -X POST -H "$who" "$P/api/v1/accounts/$m_on_p/follow"
|
||||
done
|
||||
until_true 45 '[ "$(p_follows "$AH")" = "True" ] && [ "$(p_follows "$BH")" = "True" ] && [ "$(m_followed_by alice_sync)" = "True" ] && [ "$(m_followed_by bob_sync)" = "True" ]' \
|
||||
&& ok "alice and bob follow mastouser" || ko "alice and bob never both followed mastouser"
|
||||
|
||||
m_rails "Follow.where(account: Account.find_by(uri: \"https://privapub.test/peasants/alice_sync\"), target_account: Account.find_local(\"mastouser\")).destroy_all" >/dev/null
|
||||
[ "$(m_followed_by alice_sync)" = "False" ] && ok "Mastodon forgets alice's follow" || ko "Mastodon still has alice's follow"
|
||||
m_quiet "for my followers $run"
|
||||
until_true 60 '[ "$(p_follows "$AH")" = "False" ]' && ok "PrivaPub reads mastouser's list and ends alice's follow" || ko "alice still follows mastouser on PrivaPub"
|
||||
[ "$(p_follows "$BH")" = "True" ] && ok "bob's follow stays" || ko "bob's follow ended too"
|
||||
|
||||
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow"
|
||||
until_true 45 '[ "$(p_follows "$AH")" = "True" ] && [ "$(m_followed_by alice_sync)" = "True" ]' && ok "alice follows mastouser again" || ko "alice could not follow mastouser again"
|
||||
p_mongo "db.Following.deleteMany({AvatarId:'$alice_id', TargetActorURI:'$mastouser_uri'})" >/dev/null
|
||||
[ "$(p_follows "$AH")" = "False" ] && ok "PrivaPub forgets alice's follow" || ko "PrivaPub still has alice's follow"
|
||||
m_quiet "for my followers again $run"
|
||||
until_true 60 '[ "$(m_followed_by alice_sync)" = "False" ]' && ok "PrivaPub undoes the follow only Mastodon remembered" || ko "Mastodon still has alice following mastouser"
|
||||
Reference in new issue
Block a user