Moves carry follows over; first DMs to Lemmy 0.19 and Mbin go as ChatMessage

Two owner decisions of 2026-10-05, both recorded in ROADMAP:
- After a verified Move the personas following the old account follow the new one, in the same lists, and a mute or
  block of the old account carries over, as Mastodon does it.
- A direct message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a ChatMessage,
  the one place PrivaPub decides by a server's software (invariant 17). G-0008 is closed.

Mbin addresses its private messages to the recipient's profile page, so a Create addressed to a persona's /@name now
reaches the persona. Checked live: moves 8/8, Lemmy 0.19 30/30, Mbin 26/26 with messages both ways. The software
theory runs alone, since every test's peer shares 127.0.0.1.

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 20:57:43 +02:00
1 parent f3e8cce2ed
commit 7b0377c85f
19 files changed
+309 -63

No files matched your search

+13 -7
View File
@@ -241,6 +241,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an 16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an
interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a
persona's `Join` (`Participations`, by its `/grunts/join-<id>` id), each only from the origin of what it answers. persona's `Join` (`Participations`, by its `/grunts/join-<id>` id), each only from the origin of what it answers.
17. **A server's software is for display, with one exception** (owner decision 2026-10-05): a direct message to one
account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a `ChatMessage`
(`StatusService.TakesOnlyChatMessages`), as one to an account that writes to us that way does. Nothing else may
branch on `RemoteInstance.Software`.
## Mastodon client API invariants ## Mastodon client API invariants
@@ -536,10 +540,11 @@ tools/pasture/run.sh down # removes e
`rate_limiter.yaml`. Its admin mbuser is made by its console; `peers/mbin_token.py` gets mbuser's OAuth token through `rate_limiter.yaml`. Its admin mbuser is made by its console; `peers/mbin_token.py` gets mbuser's OAuth token through
the authorization-code flow (login form, consent), since a client-credentials client acts as a bot that may not vote. the authorization-code flow (login form, consent), since a client-credentials client acts as a bot that may not vote.
Mbin names what it makes during a request after the request's host, so every call says `Host: mbin.test`, never Mbin names what it makes during a request after the request's host, so every call says `Host: mbin.test`, never
the workstation's port. Its API never starts a conversation with an account elsewhere. `scenarios/mbin.sh`, 24 the workstation's port. Its access token lasts an hour, and the scenario gets a new one when it has expired. Its API
checks and one known gap (G-0008, direct messages): magazines both ways, threads with titles, a Note to a magazine never starts a conversation with an account elsewhere, so mbuser answers in the thread alice began; Mbin addresses
as a microblog post, comments both ways, favourites and upvotes both ways, a moderator's lock, unlock and removal, the that answer to alice's profile page. `scenarios/mbin.sh`, 26 checks: magazines both ways, threads with titles, a Note
unfollow, statistics. to a magazine as a microblog post, comments both ways, favourites and upvotes both ways, private messages both ways,
a moderator's lock, unlock and removal, the unfollow, statistics.
- **NodeBB (4.16.1):** the official image on the pasture's Mongo (database `nodebb`), set up once by its automated - **NodeBB (4.16.1):** the official image on the pasture's Mongo (database `nodebb`), set up once by its automated
setup (`SETUP` with `NODEBB_*` variables, admin nbuser) into the `pasture-nodebb-config` volume, trusting the CA setup (`SETUP` with `NODEBB_*` variables, admin nbuser) into the `pasture-nodebb-config` volume, trusting the CA
through `NODE_EXTRA_CA_CERTS`; its image runs `npm install` at every start. Its API (`/api/v3`) takes a bearer token through `NODE_EXTRA_CA_CERTS`; its image runs `npm install` at every start. Its API (`/api/v3`) takes a bearer token
@@ -551,15 +556,16 @@ tools/pasture/run.sh down # removes e
rustls bundles, so `images/lemmy19` builds the tag with reqwest's `rustls-tls-native-roots` added (about ten minutes rustls bundles, so `images/lemmy19` builds the tag with reqwest's `rustls-tls-native-roots` added (about ten minutes
the first time) and the pasture's bundle is mounted as the system's. Its config names the database as `uri` (1.0: the first time) and the pasture's bundle is mounted as the system's. Its config names the database as `uri` (1.0:
`connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages `connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages
only as `ChatMessage`: a reply goes out as one, a first message cannot (G-0008). `scenarios/lemmy19.sh`, 29 checks only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30
and that gap. checks.
- **Load (`load.sh`, needs the `flood` peer):** `flood` (`flood/flood.cs`, published once into `.flood`) answers as - **Load (`load.sh`, needs the `flood` peer):** `flood` (`flood/flood.cs`, published once into `.flood`) answers as
twenty fake servers and sends signed activities at a set rate; `load.sh --rate=N --seconds=N` measures the answers, twenty fake servers and sends signed activities at a set rate; `load.sh --rate=N --seconds=N` measures the answers,
the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in
`out/load/`. `docs/LOAD.md` has the method and the runs. `out/load/`. `docs/LOAD.md` has the method and the runs.
- **Account moves (`scenarios/moves.sh`, needs gts):** two fresh GoToSocial accounts made by its admin CLI; alice follows - **Account moves (`scenarios/moves.sh`, needs gts):** two fresh GoToSocial accounts made by its admin CLI; alice follows
the old one, the new one names it as an alias (`/api/v1/accounts/alias`), the old one moves (`/api/v1/accounts/move`), the old one, the new one names it as an alias (`/api/v1/accounts/alias`), the old one moves (`/api/v1/accounts/move`),
and PrivaPub shows it `moved` while alice's follow stays. 6 checks. and PrivaPub shows it `moved` and moves alice's follow to the new one, which (locked by GoToSocial) approves it.
8 checks.
- **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which
checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
+6 -6
View File
@@ -137,7 +137,7 @@ Received:
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested | | `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused | | `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused |
| `Undo{Follow, Like, Announce}` | reverses it | | `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread | | `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows (a persona is addressed by its actor id or by its profile page, as Mbin addresses its private messages); a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept | | `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor | | `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
@@ -147,7 +147,7 @@ Received:
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`; the old account then shows where it went (`moved`). The personas following it keep following it: following the new one is theirs to do | | `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | | `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -160,10 +160,10 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account. - **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
- **Direct messages** go out as a `Note` addressed to their recipients, except to someone who writes to us as - **Direct messages** go out as a `Note` addressed to their recipients, except a message to one account elsewhere that
`ChatMessage`s (Pleroma's type, which Lemmy 0.19 and Mbin take as their only private messages): a message to that one writes to us as `ChatMessage`s (Pleroma's type), or whose server takes nothing else: Lemmy before 1.0 and Mbin, as
account alone goes out as a `ChatMessage`, to it alone, without a mention in its text. The first message to an their NodeInfo names them (owner decision 2026-10-05, the one place PrivaPub decides by a server's software). That
account that never wrote to anyone here is still a `Note`, which Lemmy 0.19 and Mbin refuse (G-0008). message goes out as a `ChatMessage`, to the account alone, without a mention in its text.
- **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server - **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
@@ -68,6 +68,7 @@ namespace PrivaPub.Tests.Domain
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException(); public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException(); public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException(); public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException(); public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException(); public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
@@ -0,0 +1,75 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// alone in its collection: every test's peer is 127.0.0.1, and the server row it writes decides for whoever writes there
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class ChatMessageSoftwareTests : 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();
}
static string NewId(RemoteActor actor, string kind) => $"{new Uri(actor.Id).GetLeftPart(UriPartial.Authority)}/{kind}/{Guid.NewGuid():N}";
// the one decision taken by a server's software (owner decision 2026-10-05): Lemmy before 1.0 and Mbin get a
// ChatMessage even from a persona answering a Note; Lemmy 1.0 a Note
[Theory]
[InlineData("lemmy", "0.19.20", "ChatMessage")]
[InlineData("mbin", "1.10.1", "ChatMessage")]
[InlineData("lemmy", "1.0.0-beta.2", "Note")]
public async Task A_direct_message_to_lemmy_before_1_0_or_mbin_is_a_chat_message(string software, string version, string sent)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var sender = new RemoteActor(_harness.Peer, "pm");
var host = new Uri(sender.Id).Host;
await DB.Default.DeleteAsync<PrivaPub.Models.Jobs.RemoteInstance>(i => i.Host == host);
await DB.Default.SaveAsync(new PrivaPub.Models.Jobs.RemoteInstance { Host = host, Software = software, SoftwareVersion = version }, token);
try
{
var id = NewId(sender, "messages");
await _harness.Deliver(sender, "/human-centipede", new JsonObject
{
["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = id, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token);
var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId
}, token);
Assert.True(answer.Ok, answer.Error);
var create = (await _harness.Outgoing(sender.Id + "/inbox")).Last(a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(sent, create["object"]!["type"]!.GetValue<string>());
}
finally
{
await DB.Default.DeleteAsync<PrivaPub.Models.Jobs.RemoteInstance>(i => i.Host == host);
}
}
}
}
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
@@ -106,6 +107,32 @@ namespace PrivaPub.Tests.Federation
Assert.NotNull(message.ConversationId); Assert.NotNull(message.ConversationId);
} }
// Mbin addresses its private messages to the recipient's profile page (its `url`), not to the actor
[Fact]
public async Task A_chat_message_addressed_to_a_personas_profile_page_reaches_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mbin = new RemoteActor(_harness.Peer, "mbuser");
var messageId = NewId(mbin, "f/object");
await _harness.Deliver(mbin, "/human-centipede", new JsonObject
{
["id"] = NewId(mbin, "f/object"), ["type"] = "Create", ["actor"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
["content"] = "<p>an answer</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
var conversation = await DB.Default.Find<DmGroup>().OneAsync(message.ConversationId, token);
Assert.Equal(new[] { alice.Id, mbin.Id }.Order(StringComparer.Ordinal), conversation.Members.Select(m => m.AvatarId).Order(StringComparer.Ordinal));
}
// Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in // Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in
// kind, anyone else with a Note // kind, anyone else with a Note
[Fact] [Fact]
+20 -1
View File
@@ -1,6 +1,7 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
@@ -49,13 +50,23 @@ namespace PrivaPub.Tests.Federation
static Task<ForeignAvatar> Stored(RemoteActor actor) => static Task<ForeignAvatar> Stored(RemoteActor actor) =>
DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
// as Mastodon does it (owner decision 2026-10-05): the follow moves to the new account, in the same lists, and a mute
// or a block of the old account carries over
[Fact] [Fact]
public async Task A_move_the_new_account_confirms_shows_the_old_account_moved_there() public async Task A_move_the_new_account_confirms_moves_the_personas_follows_lists_mutes_and_blocks()
{ {
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice"); var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var old = new RemoteActor(_harness.Peer, "old"); var old = new RemoteActor(_harness.Peer, "old");
await _harness.Remote.GetActor(old.Id, refresh: true, token);
await Follows(alice.Id, old); await Follows(alice.Id, old);
var oldAccount = await Stored(old);
var list = new PersonaList { AvatarId = alice.Id, Title = "friends" };
await DB.Default.SaveAsync(list, token);
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = alice.Id, AccountId = oldAccount.ID }, token);
await _harness.Relationships.Mute(alice, old.Id, oldAccount.ID, hideNotifications: true, duration: default, token);
await _harness.Relationships.Block(bob, old.Id, oldAccount.ID, token);
var target = Target("new", aliasOf: old); var target = Target("new", aliasOf: old);
await Move(old, old, target); await Move(old, old, target);
@@ -65,6 +76,14 @@ namespace PrivaPub.Tests.Federation
var account = await new MastodonMapper(_harness.Db, _harness.Local).Account(stored.ID, token); var account = await new MastodonMapper(_harness.Db, _harness.Local).Account(stored.ID, token);
Assert.Equal(target.Name, account.Moved?.Username); Assert.Equal(target.Name, account.Moved?.Username);
Assert.Contains(_harness.Ledger.Of("in"), e => e.Activity == "Move" && e.Reason == "moved"); Assert.Contains(_harness.Ledger.Of("in"), e => e.Activity == "Move" && e.Reason == "moved");
var newAccount = await Stored(target);
Assert.True(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == old.Id).ExecuteAnyAsync(token));
Assert.Contains(await _harness.Outgoing(target.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Follow");
Assert.Contains(await _harness.Outgoing(old.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Undo");
Assert.True(await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID && m.AccountId == newAccount.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Mute>().Match(m => m.AvatarId == alice.Id && m.TargetActorURI == target.Id).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Block>().Match(b => b.AvatarId == bob.Id && b.TargetActorURI == target.Id).ExecuteAnyAsync(token));
} }
[Fact] [Fact]
+3 -3
View File
@@ -54,6 +54,8 @@ namespace PrivaPub.Tests.Support
Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox); Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox);
Participations = new Participations(Db, Local, Delivery); Participations = new Participations(Db, Local, Delivery);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery);
Handlers = new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
@@ -72,18 +74,16 @@ namespace PrivaPub.Tests.Support
new FlagHandler(Db, Local), new FlagHandler(Db, Local),
new BlockHandler(Db, Local), new BlockHandler(Db, Local),
new LockHandler(Db), new LockHandler(Db),
new MoveHandler(Remote) new MoveHandler(Remote, Db, Local, Follows, Relationships)
}; };
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local); Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance); Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes, Approvals); Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes, Approvals);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>()); Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery);
Reports = new ReportService(Db, Local, Delivery); Reports = new ReportService(Db, Local, Delivery);
} }
+21 -3
View File
@@ -248,9 +248,10 @@ namespace PrivaPub.Domain.Statuses
.Concat(post.Mentions) .Concat(post.Mentions)
.DistinctBy(m => m.ActorURI) .DistinctBy(m => m.ActorURI)
.ToList(); .ToList();
// to someone who writes to us as ChatMessages (Lemmy 0.19 and Mbin take a private message no other way), in kind // to one account elsewhere, as a ChatMessage when it writes to us that way, or when its server takes a private message
post.AsChatMessage = recipients is [{ LocalId: null } recipient] // no other way (Lemmy 0.19 and Mbin)
&& await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token); post.AsChatMessage = recipients is [{ LocalId: null } recipient] && (await TakesOnlyChatMessages(recipient.Uri, token)
|| await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token));
var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI); var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI);
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.To = Strings(note["to"]); post.To = Strings(note["to"]);
@@ -547,6 +548,23 @@ namespace PrivaPub.Domain.Statuses
sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox); sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox);
// The one place PrivaPub decides by a server's software (owner decision 2026-10-05, G-0008): Lemmy before 1.0 and
// Mbin answer a direct Note 400 or drop it, and their actors say nothing of what they take, so the first message to
// one of their accounts goes as a ChatMessage, as NodeInfo names them
async Task<bool> TakesOnlyChatMessages(string actorUri, CancellationToken token)
{
if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri))
return false;
var host = uri.Host;
var instance = await DB.Default.Find<Models.Jobs.RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return instance?.Software?.ToLowerInvariant() switch
{
"mbin" => true,
"lemmy" => instance.SoftwareVersion is { } version && version.StartsWith("0.", StringComparison.Ordinal),
_ => false
};
}
sealed record ConversationResult(DmGroup Group, IReadOnlyList<Recipient> Recipients, int Status = StatusCodes.Status200OK, string Error = default) sealed record ConversationResult(DmGroup Group, IReadOnlyList<Recipient> Recipients, int Status = StatusCodes.Status200OK, string Error = default)
{ {
public bool Ok => Error == default; public bool Ok => Error == default;
@@ -65,6 +65,7 @@ namespace PrivaPub.Federation.Actors
Task<GoneActor> Gone(string userName, CancellationToken token); Task<GoneActor> Gone(string userName, CancellationToken token);
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token); Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
Task<LocalActor> FindByUri(string actorUri, CancellationToken token); Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> FindByAddress(string address, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token); Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token); Task<bool> IsUserNameTaken(string userName, CancellationToken token);
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token); Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
@@ -153,6 +154,16 @@ namespace PrivaPub.Federation.Actors
return FindByUserName(userName, token); return FindByUserName(userName, token);
} }
// an actor id, or the profile page its document names as `url`: Mbin addresses its private messages to that page
public Task<LocalActor> FindByAddress(string address, CancellationToken token)
{
var profile = $"{BaseAddress}/@";
if (string.IsNullOrEmpty(address) || !address.StartsWith(profile, StringComparison.OrdinalIgnoreCase))
return FindByUri(address, token);
var userName = address[profile.Length..];
return userName.Length == 0 || userName.IndexOfAny(['/', '#', '?']) >= 0 ? Task.FromResult<LocalActor>(default) : FindByUserName(userName, token);
}
public async Task<LocalActor> GetInstanceActor(CancellationToken token) public async Task<LocalActor> GetInstanceActor(CancellationToken token)
{ {
var instance = _instanceActor ??= await LoadInstanceActor(token); var instance = _instanceActor ??= await LoadInstanceActor(token);
@@ -133,12 +133,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToList(); .ToList();
var localTargets = new List<LocalActor>(); var localTargets = new List<LocalActor>();
foreach (var uri in addressed) for (var i = 0; i < addressed.Count; i++)
{ {
var local = await _localActors.FindByUri(uri, token); var local = await _localActors.FindByAddress(addressed[i], token);
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id)) if (local == default)
continue;
addressed[i] = local.Uri;
if (local.IsFederated && localTargets.All(l => l.Id != local.Id))
localTargets.Add(local); localTargets.Add(local);
} }
addressed = addressed.Distinct(StringComparer.Ordinal).ToList();
var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList(); var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
var parent = string.IsNullOrEmpty(note.InReplyTo) var parent = string.IsNullOrEmpty(note.InReplyTo)
@@ -1,7 +1,13 @@
using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Relationships;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -11,15 +17,25 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
// An account moving to another (Mastodon's Move{object: itself, target: the new account}), believed as Mastodon // An account moving to another (Mastodon's Move{object: itself, target: the new account}), believed as Mastodon
// believes it: the moving account sends it about itself, and the new account, read again from its own server, names // believes it: the moving account sends it about itself, and the new account, read again from its own server, names
// it among its alsoKnownAs. The old account then shows where it went (`moved`). The personas following it keep // it among its alsoKnownAs. The old account then shows where it went (`moved`), and, as Mastodon does it (owner
// following it: following the new one is theirs to do. // decision 2026-10-05), the personas following it follow the new one instead, in the same lists, and those that
// muted or blocked it mute or block the new one too.
public class MoveHandler : IActivityHandler public class MoveHandler : IActivityHandler
{ {
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IFollowService _follows;
readonly IRelationshipService _relationships;
public MoveHandler(IRemoteActorService remoteActors) public MoveHandler(IRemoteActorService remoteActors, DbEntities dbEntities, ILocalActorService localActors, IFollowService follows,
IRelationshipService relationships)
{ {
_remoteActors = remoteActors; _remoteActors = remoteActors;
_dbEntities = dbEntities;
_localActors = localActors;
_follows = follows;
_relationships = relationships;
} }
public string Type => "Move"; public string Type => "Move";
@@ -44,7 +60,45 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
} }
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token); await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token);
await MoveFollows(actor, moved, token);
await CarryOver(actor, moved, token);
Arrival.Accept("moved"); Arrival.Accept("moved");
} }
async Task MoveFollows(ForeignAvatar old, ForeignAvatar moved, CancellationToken token)
{
foreach (var following in await _dbEntities.Followings.Match(f => f.TargetActorURI == old.ActorURI).ExecuteAsync(token))
{
var persona = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token);
if (persona == default)
continue;
var lists = await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == persona.Id && m.AccountId == old.ID).ExecuteAsync(token);
var followed = await _follows.FollowAs(persona, moved.ActorURI, following.ShowReblogs, token);
await _follows.UnfollowAs(persona, old.ActorURI, token);
if (followed == default)
continue;
foreach (var member in lists)
try
{
await DB.Default.SaveAsync(new PersonaListMember { ListId = member.ListId, AvatarId = persona.Id, AccountId = moved.ID }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
}
async Task CarryOver(ForeignAvatar old, ForeignAvatar moved, CancellationToken token)
{
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.TargetActorURI == old.ActorURI).ExecuteAsync(token))
if (await _localActors.FindById(LocalActorKind.Person, mute.AvatarId, token) is { } persona
&& !await DB.Default.Find<Mute>().Match(m => m.AvatarId == persona.Id && m.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token))
await _relationships.Mute(persona, moved.ActorURI, moved.ID, mute.HideNotifications,
mute.ExpiresAt is { } expires ? expires - DateTime.UtcNow : default(TimeSpan?), token);
foreach (var block in await DB.Default.Find<Block>().Match(b => b.TargetActorURI == old.ActorURI).ExecuteAsync(token))
if (await _localActors.FindById(LocalActorKind.Person, block.AvatarId, token) is { } persona
&& !await DB.Default.Find<Block>().Match(b => b.AvatarId == persona.Id && b.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token))
await _relationships.Block(persona, moved.ActorURI, moved.ID, token);
}
} }
} }
+2 -1
View File
@@ -11,7 +11,8 @@ namespace PrivaPub.Models.Jobs
public DateTime? LastFailureAt { get; set; } public DateTime? LastFailureAt { get; set; }
public string LastError { get; set; } public string LastError { get; set; }
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for the one
//owner decision (2026-10-05) that a first private message to Lemmy before 1.0 or Mbin is a ChatMessage
public string SoftwareVersion { get; set; } public string SoftwareVersion { get; set; }
public string NodeName { get; set; } public string NodeName { get; set; }
public List<string> Protocols { get; set; } = new(); public List<string> Protocols { get; set; } = new();
+3 -1
View File
@@ -2,7 +2,7 @@
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-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.
Checks: 2557 pass, 4 fail, 1 known gaps, 0 known gaps now passing. Checks: 2558 pass, 6 fail, 1 known gaps, 0 known gaps now passing.
## New failures ## New failures
@@ -10,6 +10,8 @@ Checks: 2557 pass, 4 fail, 1 known gaps, 0 known gaps now passing.
- **`count.like.community|*|mastodon|control`**: 1 failing. Example: p161 expected `>=1`, got `0`. - **`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`. - **`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)`. - **`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
+25 -18
View File
@@ -142,7 +142,7 @@ Priorities, used throughout:
| Polls (see §3 Misskey for vote shapes) | P1 | `Status.poll`, `/polls/:id`, `/polls/:id/votes`, `poll` notification | | Polls (see §3 Misskey for vote shapes) | P1 | `Status.poll`, `/polls/:id`, `/polls/:id/votes`, `poll` notification |
| Custom emoji on posts, names, fields and poll options, proxied, refreshed by `updated` | P1 | `Status.emojis`, `Account.emojis` | | Custom emoji on posts, names, fields and poll options, proxied, refreshed by `updated` | P1 | `Status.emojis`, `Account.emojis` |
| Link attachments as the card source | P1 | `Status.card` | | Link attachments as the card source | P1 | `Status.card` |
| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account, 7-day lock); move each persona's follow | P1 | `Account.moved` | | Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account); move each persona's follow, lists, mutes and blocks (done 2026-10-05) | P1 | `Account.moved` |
| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` | | Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` |
| Inbound `Block`: stop delivering, hide (**done** 2026-10-04) | P2 | `relationship.blocked_by` | | Inbound `Block`: stop delivering, hide (**done** 2026-10-04) | P2 | `relationship.blocked_by` |
| `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` | | `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` |
@@ -260,7 +260,9 @@ Such posts are then found in the member's conversations, never by a search on th
**Account moves (2026-10-05, `tools/pasture/scenarios/moves.sh`):** GoToSocial moves an account through its API (the new **Account moves (2026-10-05, `tools/pasture/scenarios/moves.sh`):** GoToSocial moves an account through its API (the new
one names the old in `alsoKnownAs` first, `/api/v1/accounts/alias`) and sends `Move{object: old, target: new}` to the one names the old in `alsoKnownAs` first, `/api/v1/accounts/alias`) and sends `Move{object: old, target: new}` to the
old one's followers. PrivaPub reads the new account again, finds the old one among its aliases and shows the old account old one's followers. PrivaPub reads the new account again, finds the old one among its aliases and shows the old account
`moved`; 6 checks pass. PrivaPub dropped `Move` as an unknown type until then. `moved`. Since the owner's decision of the same day it also moves alice's follow, as Mastodon does: a Follow to the new
account (locked by GoToSocial, which approves it) and an Undo to the old; 8 checks pass. PrivaPub dropped `Move` as an
unknown type until then.
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17 ### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
@@ -515,7 +517,7 @@ on a Page: pins live in `featured`, locks in `Lock`.
| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. Votes and their undoing **done** 2026-10-04 (`AnnounceHandler.Relayed`: trusted from the community's own server or on its own posts, otherwise fetched from the voter's origin); moderation still open | P1 | — | | Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. Votes and their undoing **done** 2026-10-04 (`AnnounceHandler.Relayed`: trusted from the community's own server or on its own posts, otherwise fetched from the voter's origin); moderation still open | P1 | — |
| Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` | | Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` |
| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` | | Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` |
| `ChatMessage` in and out (out to someone who writes to us that way, done 2026-10-05; a first message waits, G-0008) | P1 | `visibility: direct` | | `ChatMessage` in and out (out to someone who writes to us that way, and to Lemmy 0.19 and Mbin by NodeInfo, done 2026-10-05) | P1 | `visibility: direct` |
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — | | Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
| Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — | | Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — |
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — | | Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — |
@@ -562,14 +564,14 @@ What it showed:
- 0.19's release trusts only the roots its rustls bundles, never Caddy's CA, so the pasture builds 0.19.20 from its tag - 0.19's release trusts only the roots its rustls bundles, never Caddy's CA, so the pasture builds 0.19.20 from its tag
with reqwest's `rustls-tls-native-roots` added (`tools/pasture/images/lemmy19`) and runs it as `lemmy19.test`. with reqwest's `rustls-tls-native-roots` added (`tools/pasture/images/lemmy19`) and runs it as `lemmy19.test`.
- **0.19 takes private messages only as `ChatMessage`** (`ChatMessageType` has no `Note`) and answers our direct - **0.19 takes private messages only as `ChatMessage`** (`ChatMessageType` has no `Note`) and answers our direct
`Note` 400. Since 2026-10-05 a direct message to someone who writes to us as `ChatMessage`s goes out as one; the `Note` 400. Its actors don't say so, so since 2026-10-05 (owner decision) a direct message to one account on a server
first message to someone who never did is still a `Note` (G-0008). Most of the threadiverse runs 0.19, so this whose NodeInfo names Lemmy before 1.0 goes out as a `ChatMessage`, a first message too. Most of the threadiverse runs
matters more than Mbin's same rule. 0.19, so this matters more than Mbin's same rule.
- **Pasture evidence (2026-10-05, Lemmy 0.19.20, `tools/pasture/scenarios/lemmy19.sh`):** 29 checks pass and one gap is - **Pasture evidence (2026-10-05, Lemmy 0.19.20, `tools/pasture/scenarios/lemmy19.sh`):** 30 checks pass: communities
expected (G-0008, a first direct message): communities both ways, threads with titles, comments both ways, votes up both ways, threads with titles, comments both ways, votes up and down both ways (relayed in the community's announces,
and down both ways (relayed in the community's announces, as 1.0 does), its private message to alice and her answer as 1.0 does), its private message to alice and her answer as a `ChatMessage` (another persona's too), a first message
as a `ChatMessage` (another persona's too), a moderator's lock (replies then refused), unlock, ban and unban to an account that never wrote here, a moderator's lock (replies then refused), unlock, ban and unban (`blocked_by`)
(`blocked_by`) and removal, statistics. and removal, statistics.
### PieFed 1.7.17 and Mbin 1.10.1 ### PieFed 1.7.17 and Mbin 1.10.1
@@ -603,14 +605,19 @@ What it showed:
answers the thread gone. answers the thread gone.
- **Votes:** an upvote is an `Announce`, a favourite a `Like`. Downvotes stay on Mbin (no `Dislike`), and taking an - **Votes:** an upvote is an `Announce`, a favourite a `Like`. Downvotes stay on Mbin (no `Dislike`), and taking an
upvote back sends nothing (its vote listener announces only the upvote), so a boost counted here stays. upvote back sends nothing (its vote listener announces only the upvote), so a boost counted here stays.
- **Private messages only as `ChatMessage`:** a direct `Note` is dropped ("PM: not implemented", G-0008), and Mbin's - **Private messages only as `ChatMessage`:** a direct `Note` is dropped ("PM: not implemented"), and Mbin's actors
actors say nothing that would let a sender choose; its API never starts a conversation with a remote account. say nothing that would let a sender choose, so PrivaPub sends a `ChatMessage` to a server whose NodeInfo names Mbin
(owner decision 2026-10-05). Its API never starts a conversation with a remote account.
- **Mbin addresses a private message to the recipient's profile page** (`apPublicUrl`, the actor's `url`), not to its
id. PrivaPub takes a persona's profile page as its address since 2026-10-05. Mbin also passes on a message it
received as though it were sending it, signed as its remote author; it has no key for that account, so the delivery
fails on Mbin's side and nothing leaves.
- Mbin names what it makes during a request after the request's host, port included. - Mbin names what it makes during a request after the request's host, port included.
- **Pasture evidence (2026-10-05, Mbin 1.10.1, `tools/pasture/scenarios/mbin.sh`):** 24 checks pass and one gap is - **Pasture evidence (2026-10-05, Mbin 1.10.1, `tools/pasture/scenarios/mbin.sh`):** 26 checks pass: magazines both
expected (G-0008): magazines both ways; an Mbin thread in our community arrives titled and ours reaches Mbin as a ways; an Mbin thread in our community arrives titled and ours reaches Mbin as a thread; a magazine's thread reaches
thread; a magazine's thread reaches alice's home and her Note becomes a microblog post in it; comments both ways; alice's home and her Note becomes a microblog post in it; comments both ways; Mbin's favourite counts as a like and
Mbin's favourite counts as a like and its upvote as a boost, alice's like and boost count there; a moderator's lock, its upvote as a boost, alice's like and boost count there; alice's first message and mbuser's answer; a moderator's
unlock and removal; the unfollow; statistics. lock, unlock and removal; the unfollow; statistics.
- **Gaps:** - **Gaps:**
- **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string. - **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string.
- **P2:** galleries; post `Move`; `repliesEnabled`; `nsfl`; flairs; publish `likes`/`shares` totals. - **P2:** galleries; post `Move`; `repliesEnabled`; `nsfl`; flairs; publish `likes`/`shares` totals.
+7 -5
View File
@@ -79,9 +79,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- wave 2, under way: PieFed, Mbin, NodeBB and Lemmy 0.19 in the pasture with scenarios (2026-10-05). What they showed and was fixed: the - wave 2, under way: PieFed, Mbin, NodeBB and Lemmy 0.19 in the pasture with scenarios (2026-10-05). What they showed and was fixed: the
instance actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal instance actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal
of a post on its own server is believed at once; a followed group's post its own server sends without announcing of a post on its own server is believed at once; a followed group's post its own server sends without announcing
it is kept, and a bare `Lock` from the post's server is taken (Mbin). A direct message to someone who writes to us as it is kept, and a bare `Lock` from the post's server is taken (Mbin). A direct message goes out as a `ChatMessage`
`ChatMessage`s goes out as one (Lemmy 0.19, Mbin). Open: a first message to such an account (G-0008, waits for the to someone who writes to us that way, and to Lemmy 0.19 and Mbin as NodeInfo names them (owner decision, G-0008
owner). closed).
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
- [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
@@ -274,6 +274,8 @@ and circles (see Owner decisions).
| Question | Decision | | Question | Decision |
|---|---| |---|---|
| May a persona join a remote event? | **Yes.** A persona joins and leaves another server's event (`Join`/`Leave`, sent to the event's organiser only). Its participation is public on that server, as on every platform with events. Events that take participants only by invitation or on another site are refused before anything is sent. | | May a persona join a remote event? | **Yes.** A persona joins and leaves another server's event (`Join`/`Leave`, sent to the event's organiser only). Its participation is public on that server, as on every platform with events. Events that take participants only by invitation or on another site are refused before anything is sent. |
| A first private message to Lemmy 0.19 or Mbin (G-0008) | **Decide by the server's software.** Lemmy before 1.0 and Mbin take a private message only as a `ChatMessage` and their actors do not say so, so a direct message to one account on a server whose NodeInfo names one of them goes as a `ChatMessage`: the one exception to "a server's software is for display only". |
| An account a persona follows moves | **Move the follow, as Mastodon does.** After a verified `Move` the persona follows the new account (a Follow to its server) and unfollows the old, in the same lists; a mute or a block of the old account carries over. |
| Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. | | Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
@@ -655,8 +657,8 @@ it, raw where it doesn't.
`interaction_policy`; `ReplyRequest`/`LikeRequest`/`AnnounceRequest` where approval is needed; `Accept{result}` `interaction_policy`; `ReplyRequest`/`LikeRequest`/`AnnounceRequest` where approval is needed; `Accept{result}`
verified and carried; replies a policy does not let in kept only with an authorization. verified and carried; replies a policy does not let in kept only with an authorization.
- **Accounts and follows:** - **Accounts and follows:**
- inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, checked live against - inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, the personas' follows,
GoToSocial); moving the personas' follows to the new account is left to them until the owner decides; lists, mutes and blocks move to the new one, owner decision; checked live against GoToSocial);
- re-run WebFinger on a rename; - re-run WebFinger on a rename;
- inbound `Block`, plus `Add`/`Remove` of pins; - inbound `Block`, plus `Add`/`Remove` of pins;
- FEP-8fcf followers sync; - FEP-8fcf followers sync;
+2 -2
View File
@@ -95,13 +95,13 @@ LBT=$(privapub_token bob_lemmy19)
curl -s -o /dev/null -X POST -H "Authorization: Bearer $LBT" $P/api/v1/statuses -d "status=@lemmyuser@lemmy19.test a word from bob $run&visibility=direct" curl -s -o /dev/null -X POST -H "Authorization: Bearer $LBT" $P/api/v1/statuses -d "status=@lemmyuser@lemmy19.test a word from bob $run&visibility=direct"
until_true 45 'lm GET "private_message/list?limit=50" | grep -q "a word from bob $run"' && ok "bob's DM to lemmyuser goes as a ChatMessage too" || ko "bob's DM never reached Lemmy 0.19" until_true 45 'lm GET "private_message/list?limit=50" | grep -q "a word from bob $run"' && ok "bob's DM to lemmyuser goes as a ChatMessage too" || ko "bob's DM never reached Lemmy 0.19"
# (0.19 takes a private message only as a ChatMessage and answers a direct Note 400: a first message to someone who # (0.19 takes a private message only as a ChatMessage and answers a direct Note 400: a first message to someone who
# never wrote to anyone here is a Note, G-0008. 1.0 takes a Note) # never wrote to anyone here goes as a ChatMessage because NodeInfo names Lemmy before 1.0, owner decision 2026-10-05)
quiet="quiet$run" quiet="quiet$run"
QT=$(site lemmy19.test -s -X POST "$LM/api/v3/user/register" -H 'Content-Type: application/json' \ QT=$(site lemmy19.test -s -X POST "$LM/api/v3/user/register" -H 'Content-Type: application/json' \
-d "{\"username\":\"$quiet\",\"password\":\"Lemmy-Pasture-Pass-1\",\"password_verify\":\"Lemmy-Pasture-Pass-1\",\"show_nsfw\":false}" | j "print(d['jwt'])") -d "{\"username\":\"$quiet\",\"password\":\"Lemmy-Pasture-Pass-1\",\"password_verify\":\"Lemmy-Pasture-Pass-1\",\"show_nsfw\":false}" | j "print(d['jwt'])")
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=@$quiet@lemmy19.test a first word from PrivaPub $run&visibility=direct" curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=@$quiet@lemmy19.test a first word from PrivaPub $run&visibility=direct"
until_true 30 'site lemmy19.test -s "$LM/api/v3/private_message/list?limit=50" -H "Authorization: Bearer $QT" | grep -q "a first word from PrivaPub $run"' \ until_true 30 'site lemmy19.test -s "$LM/api/v3/private_message/list?limit=50" -H "Authorization: Bearer $QT" | grep -q "a first word from PrivaPub $run"' \
&& ok "alice's first DM to someone there arrives" || xf "alice's first DM to someone there never arrives, a Note Lemmy 0.19 refuses (G-0008)" && ok "alice's first DM to someone there arrives as a ChatMessage" || ko "alice's first DM to someone there never arrived"
echo " moderation" echo " moderation"
p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; } p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
+13 -5
View File
@@ -1,6 +1,6 @@
# Mbin 1.10.1: the threadiverse in Symfony. Magazines both ways (FEP-1b12), threads with titles, a Note addressed to a # Mbin 1.10.1: the threadiverse in Symfony. Magazines both ways (FEP-1b12), threads with titles, a Note addressed to a
# magazine as one of its microblog posts, comments both ways, favourites (Like) and upvotes (Announce) both ways (Mbin # magazine as one of its microblog posts, comments both ways, favourites (Like) and upvotes (Announce) both ways (Mbin
# sends no downvote), a direct message (G-0008), a moderator's lock and removal, the unfollow, statistics. Mbin is driven # sends no downvote), private messages both ways, a moderator's lock and removal, the unfollow, statistics. Mbin is driven
# through its API as mbuser, with the token peers/mbin_token.py got through the authorization-code flow. # through its API as mbuser, with the token peers/mbin_token.py got through the authorization-code flow.
MB=https://mbin.test:6443 MB=https://mbin.test:6443
MT=$(cat "$here/.state/mbin.token" 2>/dev/null) MT=$(cat "$here/.state/mbin.token" 2>/dev/null)
@@ -20,6 +20,11 @@ mb_resolve() { mb GET "search/v2?q=$(python3 -c 'import sys, urllib.parse; print
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_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 '')), ''))"; }
echo "mbin" echo "mbin"
# Mbin's access token lasts an hour: a stale one is replaced through the same authorization-code flow
if [ "$(mb GET users/me | j "print(d['username'])")" != "mbuser" ]; then
python3 "$here/peers/mbin_token.py" Mbin-Pasture-Pass-1 "$here/.state/mbin/client.json" > "$here/.state/mbin.token" 2>"$here/.state/mbin/token.log"
MT=$(cat "$here/.state/mbin.token" 2>/dev/null)
fi
[ -n "$MT" ] && [ "$(mb GET users/me | j "print(d['username'])")" = "mbuser" ] && ok "Mbin token for mbuser" || { ko "Mbin token"; return 1; } [ -n "$MT" ] && [ "$(mb GET users/me | j "print(d['username'])")" = "mbuser" ] && ok "Mbin token for mbuser" || { ko "Mbin token"; return 1; }
PT=$(privapub_token alice_mbin) PT=$(privapub_token alice_mbin)
PH="Authorization: Bearer $PT" PH="Authorization: Bearer $PT"
@@ -88,11 +93,14 @@ until_true 45 '[ "$(mb GET "entry/$books_thread" | j "print(d[\"uv\"])")" = "1"
&& ok "alice's boost is an upvote on Mbin" || ko "alice's boost not counted on Mbin ($(mb GET "entry/$books_thread" | j "print(d[\"favourites\"], d[\"uv\"])"))" && ok "alice's boost is an upvote on Mbin" || ko "alice's boost not counted on Mbin ($(mb GET "entry/$books_thread" | j "print(d[\"favourites\"], d[\"uv\"])"))"
echo " private messages" echo " private messages"
# Mbin takes a private message only as a ChatMessage, and drops alice's direct Note ("PM: not implemented", G-0008); its # Mbin takes a private message only as a ChatMessage, which PrivaPub sends it since NodeInfo names Mbin (owner decision
# API never starts a conversation with an account elsewhere, so nothing comes the other way through it # 2026-10-05); its API never starts a conversation with an account elsewhere, so mbuser answers in the thread alice began
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@mbuser@mbin.test a secret from PrivaPub $run&visibility=direct" curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@mbuser@mbin.test a secret from PrivaPub $run&visibility=direct"
until_true 30 '[ "$(mb_sql "select count(*) from message where body like '"'%a secret from PrivaPub $run%'"'")" = "1" ]' \ until_true 45 '[ "$(mb_sql "select count(*) from message where body like '"'%a secret from PrivaPub $run%'"'")" = "1" ]' \
&& ok "alice's DM arrives as an Mbin private message" || xf "alice's DM never reaches Mbin, which takes private messages only as ChatMessage (G-0008)" && ok "alice's DM arrives as an Mbin private message" || ko "alice's DM never reached Mbin"
thread=$(mb_sql "select thread_id from message where body like '%a secret from PrivaPub $run%' limit 1")
mb POST "messages/thread/$thread/reply" "{\"body\":\"an Mbin answer $run\"}" >/dev/null
until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "an Mbin answer $run"' && ok "mbuser's answer arrives as a DM" || ko "Mbin's answer never reached alice"
echo " moderation" echo " moderation"
p_locked() { curl -s -H "$PH" "$P/api/v1/statuses/$books_thread_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; } p_locked() { curl -s -H "$PH" "$P/api/v1/statuses/$books_thread_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
+13 -3
View File
@@ -1,6 +1,7 @@
# Account moves into PrivaPub's view: an account alice follows on GoToSocial moves to a new account there. The new one # Account moves into PrivaPub's view: an account alice follows on GoToSocial moves to a new account there. The new one
# names the old among its aliases (/api/v1/accounts/alias), the old moves (/api/v1/accounts/move), GoToSocial sends its # names the old among its aliases (/api/v1/accounts/alias), the old moves (/api/v1/accounts/move), GoToSocial sends its
# Move to the old one's followers, and PrivaPub shows the old account moved to the new (`moved`). Needs the gts peer. # Move to the old one's followers, PrivaPub shows the old account moved to the new (`moved`) and moves alice's follow
# there. Needs the gts peer.
G=https://gts.test:6443 G=https://gts.test:6443
gcurl() { curl -k --resolve gts.test:6443:127.0.0.1 "$@"; } gcurl() { curl -k --resolve gts.test:6443:127.0.0.1 "$@"; }
MOVE_PASSWORD='Gts-Pasture-Pass-1!' MOVE_PASSWORD='Gts-Pasture-Pass-1!'
@@ -44,5 +45,14 @@ moved=$(gcurl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Beare
[ "$moved" = "202" ] || [ "$moved" = "200" ] && ok "the old account moves on GoToSocial" || ko "GoToSocial refused the move ($moved)" [ "$moved" = "202" ] || [ "$moved" = "200" ] && ok "the old account moves on GoToSocial" || ko "GoToSocial refused the move ($moved)"
until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print((d.get(\"moved\") or {}).get(\"acct\"))")" = "$new@gts.test" ]' \ until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print((d.get(\"moved\") or {}).get(\"acct\"))")" = "$new@gts.test" ]' \
&& ok "PrivaPub shows the old account moved to the new one" || ko "PrivaPub does not show the move ($(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print(d.get('moved'))"))" && ok "PrivaPub shows the old account moved to the new one" || ko "PrivaPub does not show the move ($(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print(d.get('moved'))"))"
[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$old_on_p" | j "print(d[0]['following'])")" = "True" ] \ # as Mastodon does it (owner decision 2026-10-05): alice's follow moves to the new account, which GoToSocial makes
&& ok "and alice still follows the old account: following the new one is hers to do" || ko "alice's follow of the old account changed" # locked, so it arrives as a request the new account approves
new_on_p=$(curl -s -H "$PH" "$P/api/v1/accounts/$old_on_p" | j "print((d.get('moved') or {}).get('id'))")
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$new_on_p" | j "print(d[0][\"following\"] or d[0][\"requested\"])")" = "True" ]' \
&& ok "PrivaPub moves alice's follow to the new account" || ko "alice does not follow the new account"
[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$old_on_p" | j "print(d[0]['following'] or d[0]['requested'])")" = "False" ] \
&& ok "and no longer follows the old one" || ko "alice still follows the old account"
until_true 30 '[ -n "$(gcurl -s -H "Authorization: Bearer $NT" "$G/api/v1/follow_requests" | j "print(d[0][\"id\"])")" ]'
gcurl -s -o /dev/null -X POST -H "Authorization: Bearer $NT" "$G/api/v1/follow_requests/$(gcurl -s -H "Authorization: Bearer $NT" "$G/api/v1/follow_requests" | j "print(d[0]['id'])")/authorize"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$new_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "the new account lets alice in" || ko "the new account never accepted alice"
+3 -2
View File
@@ -119,7 +119,8 @@
"phase": "P3", "phase": "P3",
"code": "lemmy 0.19.20 crates/apub/src/protocol/objects/chat_message.rs: ChatMessageType has ChatMessage alone, so a Create{Note} to a person answers 400 (Lemmy 1.0 takes a Note); mbin 1.10.1 src/Service/ActivityPub/ActivityPubContent.php getVisibility(): a Note neither public nor to the author's followers throws 'PM: not implemented'", "code": "lemmy 0.19.20 crates/apub/src/protocol/objects/chat_message.rs: ChatMessageType has ChatMessage alone, so a Create{Note} to a person answers 400 (Lemmy 1.0 takes a Note); mbin 1.10.1 src/Service/ActivityPub/ActivityPubContent.php getVisibility(): a Note neither public nor to the author's followers throws 'PM: not implemented'",
"opened": "2026-10-05", "opened": "2026-10-05",
"status": "open", "status": "closed",
"note": "Since 2026-10-05 a direct message to someone who writes to us as ChatMessages goes out as one (Post.AsChatMessage), so answers arrive. A first message to someone who never wrote to anyone here is still a Note: neither server's actors say what they take, and PrivaPub never decides by a server's software name, so that waits for the owner. Pleroma and Akkoma file a ChatMessage as a chat apart from direct messages, so sending both would show them twice. Mbin's API never starts a conversation with an account elsewhere." "note": "Closed by the owner's decision of 2026-10-05: a direct message to one account goes as a ChatMessage when the account writes to us that way, or when its server's NodeInfo names Lemmy before 1.0 or Mbin (the one place PrivaPub decides by software). Checked live: Lemmy 0.19's first message and Mbin's thread both ways.",
"closed": "2026-10-05"
} }
] ]