Relays: PrivaPub reads from the relays its configuration names

Federation:Relays names relays by their actor (or inbox) address; the instance actor follows Public at each, as Mastodon
subscribes, a minute after start and every six hours (asked again a day after no answer or a refusal, undone when a
relay is no longer named). What an accepted relay passes on comes to the federated timeline and nobody's home: a public
post it forwards (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 of a persona's is sent to a relay; sending
public posts there waits for the owner.

The pasture gains both relays (peers/relay.sh, peers/aoderelay.sh) and scenarios/relay.sh, 13 checks. The village
backlog is clean: 2574 checks pass, one known gap (Misskey's).

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 23:11:59 +02:00
1 parent 1c7d1ece9c
commit f6fc0c535c
22 files changed
+542 -24

No files matched your search

+5
View File
@@ -561,6 +561,11 @@ tools/pasture/run.sh down # removes e
checks. checks.
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins - **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks. and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks.
- **Relays (`peers/relay.sh` Activity-Relay 2.0.9 as `relay.test`, on the shared Redis's database 13;
`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.
- **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit), - **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 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 store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with
+11
View File
@@ -33,6 +33,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **Mbin 1.10.1** - **Mbin 1.10.1**
- **NodeBB 4.16.1** - **NodeBB 4.16.1**
- **Smithereen 1.0.3** - **Smithereen 1.0.3**
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, - in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
**Pleroma 2.10.2** **Pleroma 2.10.2**
@@ -250,6 +251,16 @@ The page's OpenGraph and Twitter tags give the title, description and image; the
days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy. days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy.
`Federation:FetchLinkPreviews=false` turns page fetching off. `Federation:FetchLinkPreviews=false` turns page fetching off.
## Relays
PrivaPub reads from the relays `Federation:Relays` names (by their actor's address, or their inbox's), none by default.
Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation/Relays/Relays.cs`, a minute after start
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.
## Server descriptions and the crawler ## Server descriptions and the crawler
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server). PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
+125
View File
@@ -0,0 +1,125 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// relays the configuration names: the instance actor follows Public there, and the public posts the relay passes on,
// forwarded or announced, come to the federated timeline as their authors' posts; nothing else is taken from it
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class RelayTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
{
_harness.RelayOptions.Relays.Clear();
await _harness.Relays.Reconcile(TestContext.Current.CancellationToken);
await _harness.DisposeAsync();
}
}
static CancellationToken Token => TestContext.Current.CancellationToken;
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
string Note(RemoteActor author, string text, string to = "https://www.w3.org/ns/activitystreams#Public")
{
var path = $"/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(path, new JsonObject
{
["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(to), ["published"] = DateTime.UtcNow.ToString("O")
}.ToJsonString());
return Origin(author) + path;
}
JsonObject Create(RemoteActor author, string noteId) => new()
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id }
};
// a relay the instance actor subscribes to, which accepts
async Task<RemoteActor> Subscribed()
{
var relay = new RemoteActor(_harness.Peer, "relay", type: "Service");
_harness.RelayOptions.Relays.Add(relay.Id);
await _harness.Relays.Reconcile(Token);
var follow = Assert.Single(await _harness.Outgoing(relay.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Follow");
await _harness.Deliver(relay, "/human-centipede", new JsonObject
{
["id"] = $"{relay.Id}#accept-{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = relay.Id, ["object"] = follow.DeepClone()
});
return relay;
}
[Fact]
public async Task The_instance_actor_follows_public_at_a_configured_relay_and_takes_its_answer()
{
var relay = await Subscribed();
var follow = Assert.Single(await _harness.Outgoing(relay.Id + "/inbox"));
Assert.Equal(("Follow", "https://www.w3.org/ns/activitystreams#Public"), (follow["type"]!.GetValue<string>(), follow["object"]!.GetValue<string>()));
Assert.Equal((await _harness.Local.GetInstanceActor(Token)).Uri, follow["actor"]!.GetValue<string>());
Assert.Equal(RelayState.Accepted, (await DB.Default.Find<RelaySubscription>().Match(s => s.ActorURI == relay.Id).ExecuteSingleAsync(Token)).State);
Assert.True(await _harness.Relays.Passes(relay.Id, Token));
_harness.RelayOptions.Relays.Clear();
await _harness.Relays.Reconcile(Token);
Assert.Contains(await _harness.Outgoing(relay.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Undo");
Assert.False(await DB.Default.Find<RelaySubscription>().Match(s => s.ActorURI == relay.Id).ExecuteAnyAsync(Token));
}
[Fact]
public async Task A_public_post_a_subscribed_relay_forwards_is_kept_and_one_from_anyone_else_is_not()
{
var relay = await Subscribed();
var stranger = new RemoteActor(_harness.Peer, "stranger", type: "Service");
var author = new RemoteActor(_harness.Peer, "author", _harness.Peer.B);
var relayed = Note(author, "through the relay");
var forwarded = Note(author, "through a stranger");
var followersOnly = Note(author, "for followers", author.Id + "/followers");
await _harness.Deliver(relay, "/human-centipede", Create(author, relayed));
await _harness.Deliver(stranger, "/human-centipede", Create(author, forwarded));
await _harness.Deliver(relay, "/human-centipede", Create(author, followersOnly));
var kept = await DB.Default.Find<Post>().Match(p => p.ActorURI == author.Id).ExecuteAsync(Token);
Assert.Equal(new[] { relayed }, kept.Select(p => p.ObjectURI));
Assert.Equal(PostVisibility.Public, kept[0].Visibility);
}
[Fact]
public async Task A_post_a_relay_announces_is_its_authors_never_the_relays_boost()
{
var relay = await Subscribed();
var author = new RemoteActor(_harness.Peer, "author", _harness.Peer.B);
var noteId = Note(author, "announced by the relay");
await _harness.Deliver(relay, "/human-centipede", new JsonObject
{
["id"] = $"{relay.Id}/activities/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = relay.Id,
["to"] = new JsonArray(relay.Id + "/followers"), ["object"] = noteId
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token);
Assert.Equal(author.Id, post.ActorURI);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ReblogOfPostId == post.ID).ExecuteAnyAsync(Token));
}
}
}
+9 -4
View File
@@ -14,6 +14,7 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Relays;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
@@ -57,19 +58,20 @@ namespace PrivaPub.Tests.Support
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery); Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts); Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery);
Handlers = new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes, Approvals, Participations), new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays),
new RejectHandler(Db, Local, Quotes, Approvals, Participations), new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays),
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),
new QuoteRequestHandler(Quotes), new QuoteRequestHandler(Quotes),
new DislikeHandler(Db), new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery), new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
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, Relays),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local), new FlagHandler(Db, Local),
@@ -118,6 +120,9 @@ namespace PrivaPub.Tests.Support
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
public RelationshipService Relationships { get; } public RelationshipService Relationships { get; }
public FeaturedPosts Featured { get; } public FeaturedPosts Featured { get; }
public Relays Relays { get; }
// the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile
public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new();
public ReportService Reports { get; } public ReportService Reports { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) => public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
+3 -2
View File
@@ -5,9 +5,10 @@ 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) // Raw: the activity as it came, when its own author sent it (ReplyRelay passes it on as it is); ForwardedBy: the server
// that passed it on instead (a relay, a thread's server)
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, string Raw = default) IReadOnlyList<string> SignedHeaders, DateTime ReceivedAt, string Context = default, string Raw = default, string ForwardedBy = default)
{ {
static readonly AsyncLocal<Arrival> current = new(); static readonly AsyncLocal<Arrival> current = new();
@@ -19,10 +19,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IQuoteService _quotes; readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals; readonly IInteractionApprovals _approvals;
readonly IParticipations _participations; readonly IParticipations _participations;
readonly Relays.IRelays _relays;
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) IParticipations participations = default, Relays.IRelays relays = default)
{ {
_relays = relays;
_participations = participations; _participations = participations;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
@@ -49,6 +51,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Accept("participation-answer"); Arrival.Accept("participation-answer");
return; return;
} }
if (_relays != default && await _relays.Answered(activity, actor, accepted: Type == "Accept", token))
{
Arrival.Accept("relay-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)
{ {
@@ -88,8 +95,8 @@ 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,
IParticipations participations = default) IParticipations participations = default, Relays.IRelays relays = default)
: base(dbEntities, localActors, quotes, approvals, participations) : base(dbEntities, localActors, quotes, approvals, participations, relays)
{ {
} }
@@ -38,9 +38,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
// handler is one of them; tests set it // handler is one of them; tests set it
public IEnumerable<IActivityHandler> Relays { get; set; } public IEnumerable<IActivityHandler> Relays { get; set; }
readonly PrivaPub.Federation.Relays.IRelays _relayService;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors, public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors,
IObjectRecords records, IQuoteService quotes, IServiceProvider services = default) IObjectRecords records, IQuoteService quotes, IServiceProvider services = default, PrivaPub.Federation.Relays.IRelays relays = default)
{ {
_relayService = relays;
_services = services; _services = services;
_records = records; _records = records;
_quotes = quotes; _quotes = quotes;
@@ -74,6 +77,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
} }
if (_relayService != default && await _relayService.Passes(actor.ActorURI, token))
{
await FromRelay(objectUri, token);
return;
}
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase); var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase);
var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token); var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
@@ -139,6 +147,27 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(reblog, token); await _fanout.Distribute(reblog, token);
} }
// a post a relay we subscribe to announces: kept as its author's, for the federated timeline, never as the relay's
// boost (Mastodon unwraps them the same way)
async Task FromRelay(string objectUri, CancellationToken token)
{
var held = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
var post = held ?? await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default)
{
Arrival.Drop("fetch-failed");
return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
if (held != default)
{
Arrival.Drop("duplicate");
return;
}
Arrival.Accept("relayed");
await _fanout.Distribute(post, token);
}
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token) async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{ {
Arrival.About(Value(inner, "type")); Arrival.About(Value(inner, "type"));
@@ -41,11 +41,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILinkPreviews _previews; readonly ILinkPreviews _previews;
readonly IQuoteService _quotes; readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals; readonly IInteractionApprovals _approvals;
readonly Relays.IRelays _relays;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes, IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes,
IInteractionApprovals approvals = default) IInteractionApprovals approvals = default, Relays.IRelays relays = default)
{ {
_relays = relays;
_approvals = approvals; _approvals = approvals;
_quotes = quotes; _quotes = quotes;
_previews = previews; _previews = previews;
@@ -183,8 +185,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
var inFollowedGroup = !followed && visibility is PostVisibility.Public or PostVisibility.Unlisted && note.Audience != default var inFollowedGroup = !followed && visibility is PostVisibility.Public or PostVisibility.Unlisted && note.Audience != default
&& Origin.Same(note.Audience, author.ActorURI) && Origin.Same(note.Id, note.Audience) && Origin.Same(note.Audience, author.ActorURI) && Origin.Same(note.Id, note.Audience)
&& await _dbEntities.Followings.Match(f => f.TargetActorURI == note.Audience && f.State == FollowState.Accepted).ExecuteAnyAsync(token); && await _dbEntities.Followings.Match(f => f.TargetActorURI == note.Audience && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
// a public post a relay we subscribe to passes on, for the federated timeline (read again from its origin, as any
// forwarded post)
var relayed = !followed && visibility == PostVisibility.Public && _relays != default
&& await _relays.Passes(Arrival.Current?.ForwardedBy, token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 if (visibility == PostVisibility.Direct ? persons.Count == 0
: group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup) : group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
{ {
Arrival.Drop("not-addressed"); Arrival.Drop("not-addressed");
return; return;
+1 -1
View File
@@ -78,7 +78,7 @@ 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); payload.ForwardedBy == default ? payload.Activity : default, payload.ForwardedBy);
Arrival.Current = arrival; Arrival.Current = arrival;
try try
{ {
+163
View File
@@ -0,0 +1,163 @@
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Relays
{
public interface IRelays
{
Task Reconcile(CancellationToken token);
Task<bool> Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token);
Task<bool> Passes(string actorUri, 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.
public class Relays : IRelays
{
static readonly TimeSpan AskAgain = TimeSpan.FromDays(1);
readonly IOptions<FederationOptions> _options;
readonly IRemoteActorService _remoteActors;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
volatile HashSet<string> _accepted;
public Relays(IOptions<FederationOptions> options, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery)
{
_options = options;
_remoteActors = remoteActors;
_localActors = localActors;
_delivery = delivery;
}
// subscribes to the relays the configuration names (again a day after an unanswered or refused request), and
// unsubscribes from those it no longer names
public async Task Reconcile(CancellationToken token)
{
var configured = (_options.Value.Relays ?? new()).Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).Distinct().ToList();
var known = await DB.Default.Find<RelaySubscription>().ExecuteAsync(token);
if (configured.Count == 0 && known.Count == 0)
return;
var instance = await _localActors.GetInstanceActor(token);
foreach (var gone in known.Where(k => !configured.Contains(k.Configured)))
{
if (gone.State != RelayState.Rejected && !string.IsNullOrEmpty(gone.InboxURL))
await _delivery.Enqueue(instance, new[] { gone.InboxURL }, Undo(instance, gone.FollowActivityURI), token);
await DB.Default.DeleteAsync<RelaySubscription>(gone.ID);
}
foreach (var address in configured)
{
var subscription = known.FirstOrDefault(k => k.Configured == address);
if (subscription is { State: RelayState.Accepted } || subscription != default && DateTime.UtcNow - subscription.RequestedAt < AskAgain)
continue;
var relay = await Resolve(address, token);
if (relay == default || string.IsNullOrEmpty(relay.InboxURL))
continue;
subscription ??= new RelaySubscription { Configured = address };
subscription.ActorURI = relay.ActorURI;
subscription.InboxURL = relay.InboxURL;
subscription.FollowActivityURI = instance.ActivityUri($"relay-{Guid.NewGuid():N}");
subscription.State = RelayState.Pending;
subscription.RequestedAt = DateTime.UtcNow;
subscription.AnsweredAt = default;
await DB.Default.SaveAsync(subscription, token);
await _delivery.Enqueue(instance, new[] { relay.InboxURL }, Follow(instance, subscription.FollowActivityURI), token);
}
_accepted = default;
}
// the relay's Accept or Reject of the subscription, by the Follow it answers
public async Task<bool> Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token)
{
var followId = Id(activity["object"]);
if (followId == default)
return false;
var subscription = await DB.Default.Find<RelaySubscription>().Match(s => s.FollowActivityURI == followId && s.ActorURI == actor.ActorURI)
.ExecuteFirstAsync(token);
if (subscription == default)
return false;
await DB.Default.Update<RelaySubscription>().MatchID(subscription.ID)
.Modify(s => s.State, accepted ? RelayState.Accepted : RelayState.Rejected)
.Modify(s => s.AnsweredAt, DateTime.UtcNow)
.ExecuteAsync(token);
_accepted = default;
return true;
}
// whether the actor is a relay that accepted our subscription
public async Task<bool> Passes(string actorUri, CancellationToken token)
{
if (string.IsNullOrEmpty(actorUri))
return false;
var accepted = _accepted ??= (await DB.Default.Find<RelaySubscription>().Match(s => s.State == RelayState.Accepted).ExecuteAsync(token))
.Select(s => s.ActorURI).ToHashSet(StringComparer.Ordinal);
return accepted.Contains(actorUri);
}
// the relay's actor, from its address or, for a relay named by its inbox, from the /actor beside it
async Task<ForeignAvatar> Resolve(string address, CancellationToken token)
{
var actor = await _remoteActors.GetActor(address, refresh: false, token);
if (actor != default || !address.EndsWith("/inbox", StringComparison.Ordinal))
return actor;
return await _remoteActors.GetActor(address[..^"/inbox".Length] + "/actor", refresh: false, token);
}
static JsonObject Follow(LocalActor instance, string id) => new()
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = id,
["type"] = "Follow",
["actor"] = instance.Uri,
["object"] = Addressing.Public
};
static JsonObject Undo(LocalActor instance, string followId) => new()
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = instance.ActivityUri($"relay-undo-{Guid.NewGuid():N}"),
["type"] = "Undo",
["actor"] = instance.Uri,
["object"] = new JsonObject { ["id"] = followId, ["type"] = "Follow", ["actor"] = instance.Uri, ["object"] = Addressing.Public }
};
}
// subscribes to the configured relays a minute after start, then every six hours
public sealed class RelaySubscriber(IServiceScopeFactory scopes, ILogger<RelaySubscriber> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromHours(6);
protected override async Task ExecuteAsync(CancellationToken token)
{
await Task.Delay(TimeSpan.FromMinutes(1), token);
using var timer = new PeriodicTimer(Every);
do
{
try
{
using var scope = scopes.CreateScope();
await scope.ServiceProvider.GetRequiredService<IRelays>().Reconcile(token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Relay subscriptions could not be brought up to date");
}
}
while (await timer.WaitForNextTickAsync(token));
}
}
}
+1
View File
@@ -122,6 +122,7 @@ namespace PrivaPub.Infrastructure.Data
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"), (() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"), (() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"), (() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"),
(() => DB.Default.Index<Models.Federation.RelaySubscription>().Key(r => r.Configured, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "relay"),
(() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured") (() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured")
}) })
await pair.Item1(); await pair.Item1();
@@ -11,5 +11,8 @@ namespace PrivaPub.Infrastructure.Http
// At two the inbox kept up with about 110 activities a second (tools/pasture/load.sh) // At two the inbox kept up with about 110 activities a second (tools/pasture/load.sh)
public int InboxConcurrency { get; set; } = 8; public int InboxConcurrency { get; set; } = 8;
public int DeliveryConcurrency { get; set; } = 8; public int DeliveryConcurrency { get; set; } = 8;
// relays the instance actor subscribes to, by their actor's (or inbox's) address: their public posts come to the
// federated timeline, and nothing is sent to them but the subscription
public List<string> Relays { get; set; } = new();
} }
} }
@@ -121,7 +121,9 @@ namespace PrivaPub.Middleware
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>() .AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
.AddSingleton<IJobHandler, DeliveryJobHandler>() .AddSingleton<IJobHandler, DeliveryJobHandler>()
.AddHostedService<JobWorker>() .AddHostedService<JobWorker>()
.AddHostedService<Domain.Social.FollowResender>(); .AddHostedService<Domain.Social.FollowResender>()
.AddSingleton<Federation.Relays.IRelays, Federation.Relays.Relays>()
.AddHostedService<Federation.Relays.RelaySubscriber>();
} }
public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration) public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration)
{ {
@@ -0,0 +1,24 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
// a relay the instance actor subscribes to (Federation:Relays): it passes on the public posts its other subscribers
// send it. PrivaPub only reads from it, and sends it nothing of its personas.
public class RelaySubscription : Entity
{
public string Configured { get; set; }//as the configuration names it
public string ActorURI { get; set; }
public string InboxURL { get; set; }
public string FollowActivityURI { get; set; }
public RelayState State { get; set; } = RelayState.Pending;
public DateTime RequestedAt { get; set; } = DateTime.UtcNow;
public DateTime? AnsweredAt { get; set; }
}
public enum RelayState
{
Pending,
Accepted,
Rejected
}
}
+3 -8
View File
@@ -1,17 +1,12 @@
# Interop backlog # Interop backlog
Generated by `tools/pasture/town.sh backlog --write` from `village-20261005-111857` on 2026-10-05. Do not edit by hand: fix the cause, or record a known gap in `tools/pasture/town/gaps.json`, and generate it again. Generated by `tools/pasture/town.sh backlog --write` from `village-20261005-220341` on 2026-10-05. Do not edit by hand: fix the cause, or record a known gap in `tools/pasture/town/gaps.json`, and generate it again.
Checks: 2558 pass, 6 fail, 1 known gaps, 0 known gaps now passing. Checks: 2574 pass, 0 fail, 1 known gaps, 0 known gaps now passing.
## New failures ## New failures
- **`count.boost.public|*|misskey|control`**: 1 failing. Example: p19 expected `>=1`, got `0`. None.
- **`count.like.community|*|mastodon|control`**: 1 failing. Example: p161 expected `>=1`, got `0`.
- **`count.boost.public|*|sharkey|control`**: 1 failing. Example: p175 expected `>=1`, got `0`.
- **`edit.text|privapub|gts|out`**: 1 failing. Example: p4 expected `(edited)`, got `l market harvest rain quiet tide garden festival bridge (p4)`.
- **`client.render.image.followers|privapub|decepub|local`**: 1 failing. Example: p15 expected `None`, got `picture 'picture 1 of p15' did not load`.
- **`client.render.image.public|privapub|decepub|local`**: 1 failing. Example: p33 expected `None`, got `picture 'picture 1 of p33' did not load`.
## Known gaps still failing ## Known gaps still failing
+14
View File
@@ -753,6 +753,20 @@ PeerTube's own instance account announces each new video too, which we drop: nob
follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name, follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name,
without a port, before it gives out its OAuth client. without a port, before it gives out its OAuth client.
### Relays: Activity-Relay 2.0.9 and aode-relay 0.3.129
- **Activity-Relay** takes a subscription as a `Follow` of `Public` from an actor with a shared inbox (Mastodon's way)
and passes every public activity a subscriber sends on to the others as it came, signed with the relay's key; only a
follower whose actor ends in `/relay` (LitePub's way) gets an `Announce` instead.
- **aode-relay** takes either kind and announces the post from its own actor.
- A forwarded post carries its author's LD signature when Mastodon wrote it; PrivaPub does not verify LD signatures,
so it reads every relayed post again from its origin (one signed request each). Verifying them, or FEP-8b32 proofs,
would save that request.
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/relay.sh`):** 13 checks pass: PrivaPub's instance actor
subscribes to each relay and takes its Accept; Mastodon subscribes; a public post of a Mastodon account nobody here
follows reaches the federated timeline, forwarded by Activity-Relay and announced by aode-relay (as its author's,
never as the relay's boost), and nobody's home; nothing of a persona's goes to a relay.
### Smithereen 1.0.3 ### Smithereen 1.0.3
- **Walls:** a post is a `Note`; one written on someone else's wall carries the wall (`sm:wall`, FEP-400e) as its - **Walls:** a post is a `Note`; one written on someone else's wall carries the wall (`sm:wall`, FEP-400e) as its
+3 -1
View File
@@ -682,7 +682,9 @@ it, raw where it doesn't.
- FEP-8b32 proof verification; - FEP-8b32 proof verification;
- `hs2019` with SHA-512. - `hs2019` with SHA-512.
- **Discovery:** - **Discovery:**
- a relay client for both relay styles; - 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;
- instance actor discovery (FEP-d556, FEP-2677); - instance actor discovery (FEP-d556, FEP-2677);
- `implements` (FEP-844e). - `implements` (FEP-844e).
- **Mastodon API:** ~~streaming WebSocket~~ (done 2026-10-05: `/api/v1/streaming` as a WebSocket and as server-sent - **Mastodon API:** ~~streaming WebSocket~~ (done 2026-10-05: `/api/v1/streaming` as a WebSocket and as server-sent
+10
View File
@@ -108,6 +108,16 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567 reverse_proxy pasture-nodebb:4567
} }
aoderelay.test {
tls internal
reverse_proxy pasture-aoderelay:8080
}
relay.test {
tls internal
reverse_proxy pasture-relay-server:8080
}
smithereen.test { smithereen.test {
tls internal tls internal
handle /i/* { handle /i/* {
+2 -1
View File
@@ -22,7 +22,8 @@
"Federation": { "Federation": {
"AllowPrivateNetworks": true, "AllowPrivateNetworks": true,
"AllowPlainHttp": true, "AllowPlainHttp": true,
"AcceptAnyCertificate": true "AcceptAnyCertificate": true,
"Relays": [ "https://relay.test/actor", "https://aoderelay.test/actor" ]
}, },
"Media": { "Root": "/tmp/privapub-media" }, "Media": { "Root": "/tmp/privapub-media" },
"RateLimits": { "AccountsPerMinute": 1000 }, "RateLimits": { "AccountsPerMinute": 1000 },
+21
View File
@@ -0,0 +1,21 @@
# aode-relay 0.3.129 (asonix): a relay that takes both kinds of subscription (Public, as Mastodon follows, or its actor,
# as LitePub does) and passes posts on as its own Announce, as aoderelay.test. Unrestricted, so anyone may subscribe;
# its state is in its own sled database. Its TLS client reads the system's roots, over which the pasture's bundle goes.
AODERELAY_IMAGE=${AODERELAY_IMAGE:-docker.io/asonix/relay:0.3.129}
aoderelay_up() {
podman volume exists pasture-aoderelay || podman volume create --label pasture=1 pasture-aoderelay >/dev/null
podman run -d --replace --name pasture-aoderelay --label pasture=1 --network $net -v pasture-aoderelay:/var/lib/aode-relay:U \
-v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \
-e HOSTNAME=aoderelay.test -e ADDR=0.0.0.0 -e PORT=8080 -e HTTPS=true -e RESTRICTED_MODE=false -e VALIDATE_SIGNATURES=true \
-e SLED_PATH=/var/lib/aode-relay/sled -e API_TOKEN=pasture-aoderelay-token -e PRETTY_LOG=false \
$AODERELAY_IMAGE >/dev/null
for _ in $(seq 1 60); do
site aoderelay.test -s -o /dev/null -w '%{http_code}' https://aoderelay.test:6443/actor 2>/dev/null | grep -q 200 && break
sleep 1
done
echo "aoderelay: https://aoderelay.test:6443"
}
# aoderelay_connected: the hosts subscribed to it, from its admin API
aoderelay_connected() { site aoderelay.test -s https://aoderelay.test:6443/api/v1/admin/connected -H 'X-Api-Token: pasture-aoderelay-token' | j "print('\n'.join(d.get('connected_actors', d) if isinstance(d, dict) else d))"; }
+37
View File
@@ -0,0 +1,37 @@
# Activity-Relay 2.0.9 (yukimochi): a Mastodon-style relay as relay.test. A server subscribes by following Public from an
# actor with a shared inbox; the relay then passes on, signed with its own key, every public activity another subscriber
# sends it (a LitePub-style follower, whose actor ends in /relay, gets an Announce instead). Its state is in the shared
# Redis (database 13); its actor key is made once. Go trusts the bundle the pasture mounts over the system one.
RELAY_IMAGE=${RELAY_IMAGE:-docker.io/yukimochi/activity-relay:v2.0.9}
. "$here/peers/shared.sh"
relay_up() {
shared_redis_up
local st="$here/.state/relay"
mkdir -p "$st"
[ -s "$st/actor.pem" ] || openssl genrsa -traditional -out "$st/actor.pem" 2048 2>/dev/null
chmod 644 "$st/actor.pem"
cat > "$st/config.yml" <<-EOF
ACTOR_PEM: /var/lib/relay/actor.pem
REDIS_URL: redis://redis:6379/13
RELAY_BIND: 0.0.0.0:8080
RELAY_DOMAIN: relay.test
RELAY_SERVICENAME: Pasture Relay
JOB_CONCURRENCY: 10
RELAY_ICON: https://relay.test/icon.png
RELAY_IMAGE: https://relay.test/image.png
EOF
for role in server worker; do
podman run -d --replace --name pasture-relay-$role --label pasture=1 --network $net \
-v "$st:/var/lib/relay:z,ro" -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \
$RELAY_IMAGE relay --config /var/lib/relay/config.yml $role >/dev/null
done
for _ in $(seq 1 60); do
site relay.test -s -o /dev/null -w '%{http_code}' https://relay.test:6443/actor 2>/dev/null | grep -q 200 && break
sleep 1
done
echo "relay: https://relay.test:6443"
}
# relay_subscribers: the hosts subscribed to the relay, from its Redis
relay_subscribers() { podman exec pasture-redis redis-cli -n 13 --raw keys 'relay:subscription:*' | sed 's/relay:subscription://'; }
+56
View File
@@ -0,0 +1,56 @@
# Relays as PrivaPub reads 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.
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
. "$here/peers/relay.sh"
. "$here/peers/aoderelay.sh"
p_relay_state() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'var s=db.RelaySubscription.findOne({ActorURI:"https://'$1'/actor"}); print(s ? s.State : "none")'; }
m_relay() { podman exec pasture-mastodon bin/rails runner 'r = Relay.find_or_create_by!(inbox_url: "https://'$1'/inbox"); r.enable! unless r.accepted? || r.pending?' >/dev/null 2>&1; }
m_unrelay() { podman exec pasture-mastodon bin/rails runner 'Relay.where(inbox_url: "https://'$1'/inbox").each { |r| r.disable!; r.destroy }' >/dev/null 2>&1; }
p_public_has() { [ "$(curl -s -H "$PH" "$P/api/v1/timelines/public?remote=true&limit=40" | j "print(any(s['uri'] == '$1' for s in d))")" = "True" ]; }
# relayed_post <tag>: a public post of a fresh Mastodon account nobody here follows, by its uri
relayed_post() {
local who="$1$run"
mastodon_user "$who"
mcurl -X POST -H "Authorization: Bearer $(mastodon_token "$who")" "$M/api/v1/statuses" -d "status=a post only the $1 brings $run&visibility=public" | j "print(d['uri'])"
}
echo "relay"
PT=$(privapub_token alice_relay)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_relay" || { ko "PrivaPub token for alice_relay"; return 1; }
run=$(date +%s)
echo " Activity-Relay"
# PrivaPub subscribes a minute after it starts (and again six hours later while unanswered)
until_true 60 'relay_subscribers | grep -qx privapub.test' && ok "PrivaPub's instance actor subscribes to Activity-Relay" || ko "PrivaPub never subscribed ($(relay_subscribers | tr '\n' ' '))"
# (1 = Accepted)
until_true 30 '[ "$(p_relay_state relay.test)" = "1" ]' && ok "and takes its Accept" || ko "PrivaPub's subscription is $(p_relay_state relay.test)"
m_relay relay.test
until_true 45 'relay_subscribers | grep -qx mastodon.test' && ok "Mastodon subscribes too" || ko "Mastodon never subscribed"
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"
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"
echo " aode-relay"
until_true 30 '[ "$(p_relay_state aoderelay.test)" = "1" ]' && ok "PrivaPub's instance actor subscribes to aode-relay, which accepts" || ko "PrivaPub's aode-relay subscription is $(p_relay_state aoderelay.test)"
m_relay aoderelay.test
until_true 45 'aoderelay_connected | grep -q mastodon.test' && ok "Mastodon subscribes too" || ko "Mastodon never subscribed to aode-relay ($(aoderelay_connected | tr '\n' ' '))"
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"
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"