One delivery a server; BookWyrm 0.9.3 in the pasture

An activity now goes once to each server, to its shared inbox when it has one, for the accounts a post names, answers
or quotes as for its followers, as Mastodon delivers (a circle post excepted: each member's copy names that member).
BookWyrm took the same post twice when one copy reached its shared inbox and another the named account's inbox at once.
SharedInboxTests checks a reply to a follower's post goes once.

BookWyrm joins the pasture (peers/bookwyrm.sh: its image on the shared Postgres and Redis, gunicorn and a Celery worker,
its user, book and statuses made in its Django shell). scenarios/bookwyrm.sh: follows both ways, a review, a comment and
a quotation reaching alice as BookWyrm's pure posts, her like, boost and reply landing there, her post naming bwuser and
bwuser's like and reply, a deletion, the unfollow and statistics: 20 checks. GoToSocial (64), Mastodon (57) and Misskey
(35) still pass.

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 01:56:06 +02:00
1 parent 12b51a211a
commit 53158b4cf4
9 files changed
+243 -21

No files matched your search

+4
View File
@@ -561,6 +561,10 @@ tools/pasture/run.sh down # removes e
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, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks.
- **BookWyrm (0.9.3):** its image, migrated, on the shared Postgres (database `bookwyrm`) and Redis (databases 14
and 15), gunicorn and one Celery worker (federation runs there). It has no client API: bwuser (named
`bwuser@bookwyrm.test`, as its signup names users), a book and every status come from its Django shell
(`bookwyrm_shell`, `-v 0`). `scenarios/bookwyrm.sh`, 20 checks.
- **Ghost (6.67) with its ActivityPub service (1.2.14):** Ghost and the service on the shared MySQL (databases `ghost`
and `activitypub`, the service's migrations run first), routed by Caddy as Ghost's own proxy does. The owner comes
from Ghost's setup API (a password that repeats the site's name is refused), staff device verification is off, and
+4 -1
View File
@@ -38,6 +38,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **WriteFreely 0.17.2**
- **Owncast 0.3.0**
- **Ghost 6.67** with its ActivityPub service 1.2.14
- **BookWyrm 0.9.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**,
**Pleroma 2.10.2**
@@ -330,7 +331,9 @@ Posts with a location (shown to nearby users of this server) never leave the ser
votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its
own (`announce-{boost}`, `like-{favourite}`), so one given again after its `Undo` is new to the server it reaches.
- **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag.
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
- **Delivery.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it
follows, names or answers (a circle post excepted: each member's copy names that member). Failed deliveries are
retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma
does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the
persona follows once more, at most once an hour.
@@ -0,0 +1,50 @@
using PrivaPub.Domain.Statuses;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// one delivery a server: a reply to a follower's post goes to its server's shared inbox once, not also to the
// follower's own inbox (BookWyrm kept two copies of a post that reached both at once)
[Trait("Category", "Integration")]
public sealed class SharedInboxTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_reply_to_a_followers_post_reaches_its_server_once_through_the_shared_inbox()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob", sharedInbox: true);
await _harness.FollowedBy(alice, bob);
var path = $"/notes/{Guid.NewGuid():N}";
var noteId = new Uri(bob.Id).GetLeftPart(UriPartial.Authority) + path;
_harness.Peer.Serve(path, new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["content"] = "<p>a question</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
}.ToJsonString());
var parent = await _harness.RemotePosts.StoreContext(noteId, 0, token);
var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an answer", InReplyTo = parent.ID }, token);
Assert.True(reply.Ok, reply.Error);
Assert.Single(await _harness.Outgoing(bob.SharedInbox), a => a["type"]!.GetValue<string>() == "Create");
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
}
}
}
+23 -14
View File
@@ -13,12 +13,15 @@ namespace PrivaPub.Tests.Support
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
readonly bool _namesSharedInbox;
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
_namesSharedInbox = sharedInbox;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
@@ -29,21 +32,27 @@ namespace PrivaPub.Tests.Support
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public JsonObject Document() => new()
public JsonObject Document()
{
["id"] = Id,
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["featured"] = Id + "/featured",
["publicKey"] = new JsonObject
var document = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
["id"] = Id,
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["featured"] = Id + "/featured",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
if (_namesSharedInbox)
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
return document;
}
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
{
+12 -6
View File
@@ -58,15 +58,15 @@ namespace PrivaPub.Federation.Outbox
foreach (var uri in addressed)
{
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
inboxes.Add(actor.InboxURL);
if (Preferred(actor) is { } inbox)
inboxes.Add(inbox);
}
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL) && !blockers.Contains(parentAuthor.ActorURI))
inboxes.Add(parentAuthor.InboxURL);
if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI))
inboxes.Add(inbox);
}
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
@@ -75,13 +75,19 @@ namespace PrivaPub.Federation.Outbox
var quotedAuthor = quoted is { IsFederatedCopy: true }
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
: default;
if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
inboxes.Add(quotedAuthor.InboxURL);
if (Preferred(quotedAuthor) is { } inbox)
inboxes.Add(inbox);
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
// a server's shared inbox when it has one, as Mastodon delivers: one delivery a server, whoever on it is named, and
// none of the races a server runs into when the same activity reaches two of its inboxes at once (BookWyrm kept
// two copies of a post that named a follower)
static string Preferred(ForeignAvatar actor) =>
actor == default ? default : !string.IsNullOrEmpty(actor.SharedInboxURL) ? actor.SharedInboxURL : actor.InboxURL is { Length: > 0 } inbox ? inbox : default;
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
+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,
without a port, before it gives out its OAuth client.
### BookWyrm 0.9.3
- Its statuses are `Review`, `Comment` and `Quotation` about a book (`inReplyToBook`) for other BookWyrms, and "pure"
`Article`s and `Note`s (the book named in the text) for everyone else, chosen by the recipient server's NodeInfo:
PrivaPub gets the pure ones.
- It keeps nothing of others' but what concerns a book or one of its accounts (a reply to its statuses, a mention).
- WebFinger finds a local user only under the username its signup gives it, `localname@domain`.
- It took the same post twice when one copy reached its shared inbox and another the named account's inbox at once.
Since 2026-10-05 PrivaPub delivers once a server, to its shared inbox, as Mastodon does.
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/bookwyrm.sh`):** 20 checks pass: follows both ways; a
review, a comment and a quotation reach alice; her like, boost and reply land there; her post naming bwuser lands, and
bwuser's like and reply reach her; a deletion; the unfollow; statistics. BookWyrm has no client API, so the scenario
acts in its Django shell, as its views do.
### Ghost 6.67 and its ActivityPub service 1.2.14
- A publication is one actor, `@index@<site>` (`/.ghost/activitypub/users/index`), served by a separate service
+5
View File
@@ -108,6 +108,11 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567
}
bookwyrm.test {
tls internal
reverse_proxy pasture-bookwyrm:8000
}
ghost.test {
tls internal
handle /.ghost/activitypub/* {
+47
View File
@@ -0,0 +1,47 @@
# BookWyrm 0.9.3: reading logs and book reviews. Its statuses are Reviews, Comments and Quotations on a book, sent to
# other BookWyrms as such and to everyone else as "pure" Notes and Articles. Its image on the shared Postgres (database
# bookwyrm) and Redis (databases 14 and 15, activity and broker), gunicorn and one Celery worker (federation runs in
# it), as bookwyrm.test; Python trusts the bundle the pasture gives requests. It has no client API: bwuser, a book and
# the statuses are made in its Django shell (bookwyrm_shell), as its own views make them.
BOOKWYRM_IMAGE=${BOOKWYRM_IMAGE:-ghcr.io/bookwyrm-social/bookwyrm:v0.9.3}
BOOKWYRM_PASSWORD=Bookwyrm-Pasture-Pass-1
. "$here/peers/shared.sh"
bookwyrm_env() {
echo "-e DEBUG=false -e DOMAIN=bookwyrm.test -e EMAIL=admin@bookwyrm.test -e SECRET_KEY=pasture-bookwyrm-not-a-secret-0123456789 \
-e POSTGRES_HOST=postgres -e POSTGRES_USER=pasture -e POSTGRES_PASSWORD=pasture -e POSTGRES_DB=bookwyrm -e PGPORT=5432 \
-e REDIS_ACTIVITY_HOST=redis -e REDIS_ACTIVITY_PORT=6379 -e REDIS_ACTIVITY_DB_INDEX=14 \
-e REDIS_BROKER_HOST=redis -e REDIS_BROKER_PORT=6379 -e REDIS_BROKER_DB_INDEX=15 \
-e EMAIL_HOST=localhost -e EMAIL_PORT=25 -e EMAIL_HOST_USER=pasture -e EMAIL_HOST_PASSWORD=pasture -e EMAIL_USE_TLS=false -e ENABLE_THUMBNAIL_GENERATION=false -e ENABLE_PREVIEW_IMAGES=false \
-e REQUESTS_CA_BUNDLE=/pasture/ca/bundle.pem -e SSL_CERT_FILE=/pasture/ca/bundle.pem -e STATIC_ROOT=static/ -e MEDIA_ROOT=images/"
}
bookwyrm_up() {
shared_postgres_up
shared_redis_up
pg_db bookwyrm
podman run --rm --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" $BOOKWYRM_IMAGE python manage.py migrate --noinput >/dev/null 2>&1
podman run --rm --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" $BOOKWYRM_IMAGE python manage.py initdb >/dev/null 2>&1 || true
podman run -d --replace --name pasture-bookwyrm --label pasture=1 --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" \
$BOOKWYRM_IMAGE gunicorn bookwyrm.wsgi:application --bind 0.0.0.0:8000 >/dev/null
podman run -d --replace --name pasture-bookwyrm-celery --label pasture=1 --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" \
$BOOKWYRM_IMAGE celery -A celerywyrm worker --pool=threads --concurrency=4 -l info \
-Q high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc >/dev/null
for _ in $(seq 1 60); do
site bookwyrm.test -s -o /dev/null -w '%{http_code}' https://bookwyrm.test:6443/nodeinfo/2.0 2>/dev/null | grep -q 200 && break
sleep 2
done
bookwyrm_shell "
from bookwyrm import models
if not models.User.objects.filter(localname='bwuser').exists():
# its signup names a local user localname@domain, which WebFinger looks for
models.User.objects.create_user('bwuser@bookwyrm.test', 'bwuser@bookwyrm.test', '$BOOKWYRM_PASSWORD', localname='bwuser', local=True)
if not models.Edition.objects.filter(title='The Pasture Book').exists():
work = models.Work.objects.create(title='The Pasture Book')
models.Edition.objects.create(title='The Pasture Book', parent_work=work)
" >/dev/null
echo "bookwyrm: https://bookwyrm.test:6443"
}
# bookwyrm_shell <python>: runs in BookWyrm's Django shell, its output printed
bookwyrm_shell() { podman exec pasture-bookwyrm python manage.py shell -v 0 -c "$1" 2>/dev/null; }
+84
View File
@@ -0,0 +1,84 @@
# BookWyrm 0.9.3: follows both ways; a review, a comment and a quotation on a book reach alice as the "pure" posts
# BookWyrm sends everyone but other BookWyrms; alice's reply, like and boost land there; her post naming bwuser lands
# too (BookWyrm keeps nothing else of others'), and bwuser's like and reply reach her; a deletion; both unfollows;
# statistics. Driven through BookWyrm's Django shell (peers/bookwyrm.sh), as its own
# views make these.
. "$here/peers/bookwyrm.sh"
# bw <python>: in BookWyrm's shell, with bw (bwuser), book and remote(uri) at hand; prints what the code prints
bw() {
bookwyrm_shell "
from bookwyrm import models, activitypub
bw = models.User.objects.get(localname='bwuser')
book = models.Edition.objects.get(title='The Pasture Book')
def remote(uri, model=models.User):
return activitypub.resolve_remote_id(uri, model=model)
$1"
}
p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if '$1' in ((s.get('reblog') or s)['content'] or '') or '$1' in (((s.get('reblog') or s).get('privapub') or {}).get('title') or '')), ''))"; }
p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; }
echo "bookwyrm"
[ "$(bw "print(bw.remote_id)")" = "https://bookwyrm.test/user/bwuser" ] && ok "BookWyrm's shell as bwuser" || { ko "BookWyrm's shell"; return 1; }
PT=$(privapub_token alice_bw)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_bw" || { ko "PrivaPub token for alice_bw"; return 1; }
alice_uri="$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['url'])" | sed 's|/@|/peasants/|')"
run=$(date +%s)
echo " follows"
bw "
alice = remote('$alice_uri')
models.UserFollowRequest.objects.get_or_create(user_subject=bw, user_object=alice)" >/dev/null
until_true 45 '[ "$(bw "print(bw.following.filter(remote_id=\"$alice_uri\").exists())")" = "True" ]' \
&& ok "bwuser follows alice (Accept arrived)" || ko "alice's Accept never reached BookWyrm"
bw_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=bwuser@bookwyrm.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$bw_on_p" ] && ok "PrivaPub resolves bwuser" || ko "PrivaPub cannot resolve bwuser"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$bw_on_p/follow"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$bw_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows bwuser (Accept arrived)" || ko "BookWyrm's Accept never arrived"
echo " reading"
bw "models.Review.objects.create(user=bw, book=book, name='A pasture review $run', content='<p>Four stars for the meadow.</p>', rating=4, privacy='public')" >/dev/null
until_true 60 '[ -n "$(p_home_has "A pasture review $run")" ]' && ok "bwuser's review reaches alice" || ko "bwuser's review never reached alice"
bw "models.Comment.objects.create(user=bw, book=book, content='<p>A comment on the book $run</p>', privacy='public')" >/dev/null
until_true 60 '[ -n "$(p_home_has "A comment on the book $run")" ]' && ok "bwuser's comment reaches alice" || ko "bwuser's comment never reached alice"
comment_on_p=$(p_home_has "A comment on the book $run")
comment_uri=$(curl -s -H "$PH" "$P/api/v1/statuses/$comment_on_p" | j "print(d['uri'])")
bw "models.Quotation.objects.create(user=bw, book=book, quote='<p>The grass is greener $run</p>', content='<p>So true.</p>', privacy='public')" >/dev/null
until_true 60 '[ -n "$(p_home_has "The grass is greener $run")" ]' && ok "bwuser's quotation reaches alice" || ko "bwuser's quotation never reached alice"
echo " alice answers"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$comment_on_p/favourite"
until_true 45 '[ "$(bw "print(models.Favorite.objects.filter(status__remote_id=\"$comment_uri\", user__remote_id=\"$alice_uri\").exists())")" = "True" ]' \
&& ok "alice's like lands on BookWyrm" || ko "alice's like never reached BookWyrm"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$comment_on_p/reblog"
until_true 45 '[ "$(bw "print(models.Boost.objects.filter(boosted_status__remote_id=\"$comment_uri\", user__remote_id=\"$alice_uri\").exists())")" = "True" ]' \
&& ok "alice's boost lands on BookWyrm" || ko "alice's boost never reached BookWyrm"
reply_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@bwuser@bookwyrm.test a PrivaPub reply $run&in_reply_to_id=$comment_on_p&visibility=public" | j "print(d['uri'])")
until_true 45 '[ "$(bw "print(models.Status.objects.filter(remote_id=\"$reply_uri\", reply_parent__remote_id=\"$comment_uri\").exists())")" = "True" ]' \
&& ok "alice's reply threads under bwuser's comment" || ko "alice's reply never reached BookWyrm"
echo " bwuser answers"
# (BookWyrm keeps only what concerns books or its own accounts: a post naming bwuser)
p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@bwuser@bookwyrm.test a PrivaPub post for BookWyrm $run&visibility=public")
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_uri=$(echo "$p_post" | j "print(d['uri'])")
until_true 45 '[ "$(bw "print(models.Status.objects.filter(remote_id=\"$p_uri\").exists())")" = "True" ]' \
&& ok "alice's post naming bwuser reaches BookWyrm" || ko "alice's post never reached BookWyrm"
bw "models.Favorite.objects.create(user=bw, status=models.Status.objects.get(remote_id='$p_uri'))" >/dev/null
until_true 45 '[ "$(p_status $p_post_id favourites_count)" = "1" ]' && ok "bwuser's like counts on PrivaPub" || ko "bwuser's like never counted"
bw "models.Status.objects.create(user=bw, content='<p>a BookWyrm reply $run</p>', reply_parent=models.Status.objects.get(remote_id='$p_uri'), privacy='public')" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a BookWyrm reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "bwuser's reply threads under alice's post" || ko "bwuser's reply missing on PrivaPub"
echo " deletions"
bw "models.Comment.objects.get(remote_id='$comment_uri').delete()" >/dev/null
until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$comment_on_p")" = "404" ]' \
&& ok "bwuser's deletion reaches PrivaPub" || ko "the deleted comment still shows on PrivaPub"
echo " unfollows"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$bw_on_p/unfollow"
until_true 45 '[ "$(bw "print(bw.followers.filter(remote_id=\"$alice_uri\").exists())")" = "False" ]' \
&& ok "alice's unfollow reaches BookWyrm" || ko "BookWyrm still counts alice"
echo " statistics"
stats_check bookwyrm.test bookwyrm