An account that moves shows where it went

PrivaPub dropped Move as an unknown type and showed no `moved` on accounts.
Now a Move is believed as Mastodon believes it: the moving account sends it
about itself, and the new account, read again from its own server, names it
in alsoKnownAs (now kept on remote accounts). The old account then shows the
new one as `moved` in the Mastodon API. The personas following it keep
following it: following the new account on their behalf would tell another
server about them, so that waits for the owner.

Checked live against GoToSocial (scenarios/moves.sh: an alias, a move, 6
checks).

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 18:39:46 +02:00
1 parent 1265611f9e
commit f1e743c331
14 files changed
+224 -3

No files matched your search

+3
View File
@@ -553,6 +553,9 @@ tools/pasture/run.sh down # removes e
`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
and that gap.
- **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`),
and PrivaPub shows it `moved` while alice's follow stays. 6 checks.
- **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
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
+1
View File
@@ -147,6 +147,7 @@ Received:
| `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 |
| `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 |
| `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}`,
+87
View File
@@ -0,0 +1,87 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// An account moving to another (Mastodon's Move), believed as Mastodon believes it: sent by the moving account about
// itself, and the new account naming it among its alsoKnownAs
[Trait("Category", "Integration")]
public sealed class MoveTests : 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();
}
// the new account, serving a document that names the old one (or not)
RemoteActor Target(string name, RemoteActor aliasOf)
{
var target = new RemoteActor(_harness.Peer, name);
var document = target.Document();
if (aliasOf != default)
document["alsoKnownAs"] = new JsonArray(aliasOf.Id);
_harness.Peer.Serve(new Uri(target.Id).AbsolutePath, document.ToJsonString());
return target;
}
Task Move(RemoteActor sender, RemoteActor moved, RemoteActor target) =>
_harness.Deliver(sender, "/human-centipede", new JsonObject
{
["id"] = NewId(sender, "moves"), ["type"] = "Move", ["actor"] = sender.Id, ["object"] = moved.Id, ["target"] = target.Id
});
static Task<ForeignAvatar> Stored(RemoteActor actor) =>
DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task A_move_the_new_account_confirms_shows_the_old_account_moved_there()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var old = new RemoteActor(_harness.Peer, "old");
await Follows(alice.Id, old);
var target = Target("new", aliasOf: old);
await Move(old, old, target);
var stored = await Stored(old);
Assert.Equal(target.Id, stored.MovedToURL);
var account = await new MastodonMapper(_harness.Db, _harness.Local).Account(stored.ID, token);
Assert.Equal(target.Name, account.Moved?.Username);
Assert.Contains(_harness.Ledger.Of("in"), e => e.Activity == "Move" && e.Reason == "moved");
}
[Fact]
public async Task A_move_the_new_account_does_not_confirm_or_someone_else_sends_changes_nothing()
{
var (_, alice) = await _harness.Persona("alice");
var old = new RemoteActor(_harness.Peer, "old");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await Follows(alice.Id, old);
var unconfirmed = Target("new", aliasOf: default);
var hijack = Target("hijack", aliasOf: stranger);
await Move(old, old, unconfirmed);
await Move(stranger, old, hijack);
Assert.Null((await Stored(old)).MovedToURL);
Assert.Equal(new[] { "not-aliased", "not-self" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Move").Select(e => e.Reason));
}
}
}
+2 -1
View File
@@ -71,7 +71,8 @@ namespace PrivaPub.Tests.Support
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local),
new BlockHandler(Db, Local),
new LockHandler(Db)
new LockHandler(Db),
new MoveHandler(Remote)
};
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
@@ -32,6 +32,7 @@
public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new();
public Source Source { get; set; }
public Account Moved { get; set; }//the account it moved to, when that account names it among its aliases
}
public class CustomEmojiEntity
@@ -166,8 +166,19 @@ namespace PrivaPub.Api.Mastodon.Mappers
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
foreach (var foreign in foreigns)
accounts[foreign.ID] = Foreign(foreign);
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
var movedTo = foreigns.Where(f => !string.IsNullOrEmpty(f.MovedToURL)).Select(f => f.MovedToURL).Distinct().ToList();
if (movedTo.Count > 0)
{
var targets = (await _dbEntities.ForeignAvatars.Match(f => movedTo.Contains(f.ActorURI) && !f.DeletionAt.HasValue).ExecuteAsync(token))
.ToDictionary(f => f.ActorURI);
foreach (var foreign in foreigns)
if (foreign.MovedToURL != default && targets.TryGetValue(foreign.MovedToURL, out var target) && target.AlsoKnownAs.Contains(foreign.ActorURI))
accounts[foreign.ID].Moved = Foreign(target);
}
return accounts;
}
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Actors
public string IconDescription { get; init; }
public string HeaderDescription { get; init; }
public string MovedTo { get; init; }
public List<string> AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one)
public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new();
@@ -41,6 +42,14 @@ namespace PrivaPub.Federation.Actors
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
// a property of URIs: one, an array of them, or objects carrying an id
static List<string> Uris(JsonElement value) => (value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToList() : new List<JsonElement> { value })
.Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : RemoteActorService.Text(v, "id"))
.Where(v => Uri.TryCreate(v, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
.Distinct(StringComparer.Ordinal)
.Take(20)
.ToList();
public static ActorDocument Parse(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
@@ -75,6 +84,7 @@ namespace PrivaPub.Federation.Actors
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
MovedTo = RemoteActorService.Text(root, "movedTo"),
AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
? published.UtcDateTime
: default(DateTime?),
@@ -214,6 +214,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.IsLocked, actor.Locked)
.Modify(a => a.IsMemorial, actor.Memorial)
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs)
.Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields)
@@ -0,0 +1,50 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
// 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
// it among its 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.
public class MoveHandler : IActivityHandler
{
readonly IRemoteActorService _remoteActors;
public MoveHandler(IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
}
public string Type => "Move";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var target = Id(activity["target"]);
if (Id(activity["object"]) != actor.ActorURI || string.IsNullOrEmpty(target) || target == actor.ActorURI)
{
Arrival.Drop("not-self");
return;
}
var moved = await _remoteActors.GetActor(target, refresh: true, token);
if (moved == default)
{
Arrival.Drop("target-unavailable");
return;
}
if (!moved.AlsoKnownAs.Contains(actor.ActorURI))
{
Arrival.Drop("not-aliased");
return;
}
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token);
Arrival.Accept("moved");
}
}
}
@@ -94,6 +94,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, BlockHandler>()
.AddSingleton<IActivityHandler, LockHandler>()
.AddSingleton<IActivityHandler, MoveHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
.AddSingleton<IActivityHandler, UpdateHandler>()
+1
View File
@@ -76,6 +76,7 @@ namespace PrivaPub.Models.User
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
public string PictureURL { get; set; }//icon
public string ThumbnailURL { get; set; }//image
public AvatarType AvatarType { get; set; } = AvatarType.Person;
+5
View File
@@ -257,6 +257,11 @@ neither the public nor the author's followers as a direct message, like our DMs,
mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently.
Such posts are then found in the member's conversations, never by a search on their URI.
**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
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.
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
Firefish is dead (its site has answered 410 since February 2025).
+2 -1
View File
@@ -651,7 +651,8 @@ it, raw where it doesn't.
`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.
- **Accounts and follows:**
- inbound `Move` with Mastodon's checks;
- inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, checked live against
GoToSocial); moving the personas' follows to the new account is left to them until the owner decides;
- re-run WebFinger on a rename;
- inbound `Block`, plus `Add`/`Remove` of pins;
- FEP-8fcf followers sync;
+48
View File
@@ -0,0 +1,48 @@
# 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
# Move to the old one's followers, and PrivaPub shows the old account moved to the new (`moved`). Needs the gts peer.
G=https://gts.test:6443
gcurl() { curl -k --resolve gts.test:6443:127.0.0.1 "$@"; }
MOVE_PASSWORD='Gts-Pasture-Pass-1!'
# gts_account <name>: a confirmed GoToSocial account and its token
gts_account() {
podman exec pasture-gts /gotosocial/gotosocial admin account create --username "$1" --email "$1@gts.test" --password "$MOVE_PASSWORD" >/dev/null 2>&1
podman exec pasture-gts /gotosocial/gotosocial admin account confirm --username "$1" >/dev/null 2>&1
local app id secret code jar
jar=$(mktemp); app=$(gcurl -s -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
id=$(echo "$app" | j "print(d['client_id'])"); secret=$(echo "$app" | j "print(d['client_secret'])")
gcurl -s -o /dev/null -c "$jar" -b "$jar" "$G/oauth/authorize?client_id=$id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
gcurl -s -o /dev/null -c "$jar" -b "$jar" -X POST $G/auth/sign_in --data-urlencode "username=$1@gts.test" --data-urlencode "password=$MOVE_PASSWORD"
code=$(gcurl -s -o /dev/null -w '%{redirect_url}' -c "$jar" -b "$jar" -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
rm -f "$jar"
gcurl -s -X POST $G/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$id&client_secret=$secret&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])"
}
echo "moves"
PT=$(privapub_token alice)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice" || { ko "PrivaPub token"; return 1; }
run=$(date +%s)
old="mvold$run"; new="mvnew$run"
OT=$(gts_account "$old"); NT=$(gts_account "$new")
[ -n "$OT" ] && [ -n "$NT" ] && ok "two GoToSocial accounts, the one that moves and the one it moves to" || { ko "GoToSocial accounts"; return 1; }
echo " following the old account"
old_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$old@gts.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$old_on_p/follow"
# (GoToSocial makes its accounts locked: the old one approves alice)
until_true 30 '[ -n "$(gcurl -s -H "Authorization: Bearer $OT" "$G/api/v1/follow_requests" | j "print(d[0][\"id\"])")" ]'
gcurl -s -o /dev/null -X POST -H "Authorization: Bearer $OT" "$G/api/v1/follow_requests/$(gcurl -s -H "Authorization: Bearer $OT" "$G/api/v1/follow_requests" | j "print(d[0]['id'])")/authorize"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$old_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows the account that will move" || ko "alice's follow of the old account never took"
echo " the move"
gcurl -s -o /dev/null -X POST -H "Authorization: Bearer $NT" "$G/api/v1/accounts/alias" -H 'Content-Type: application/json' \
-d "{\"also_known_as_uris\":[\"https://gts.test/users/$old\"]}"
moved=$(gcurl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $OT" "$G/api/v1/accounts/move" -H 'Content-Type: application/json' \
-d "{\"password\":\"$MOVE_PASSWORD\",\"moved_to_uri\":\"https://gts.test/users/$new\"}")
[ "$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" ]' \
&& 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" ] \
&& 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"