From f1e743c3314b6203dff11dcf198fb03c61f92913 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 18:39:46 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 + FEDERATION.md | 1 + PrivaPub.Tests/Federation/MoveTests.cs | 87 +++++++++++++++++++ PrivaPub.Tests/Support/Harness.cs | 3 +- PrivaPub/Api/Mastodon/Entities/Entities.cs | 1 + .../Api/Mastodon/Mappers/MastodonMapper.cs | 13 ++- PrivaPub/Federation/Actors/ActorDocument.cs | 10 +++ .../Federation/Actors/RemoteActorService.cs | 1 + .../Federation/Inbox/Handlers/MoveHandler.cs | 50 +++++++++++ .../Middleware/SocialPubConfigurations.cs | 1 + PrivaPub/Models/User/Avatar.cs | 1 + docs/INTEROP.md | 5 ++ docs/ROADMAP.md | 3 +- tools/pasture/scenarios/moves.sh | 48 ++++++++++ 14 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 PrivaPub.Tests/Federation/MoveTests.cs create mode 100644 PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs create mode 100644 tools/pasture/scenarios/moves.sh diff --git a/CLAUDE.md b/CLAUDE.md index 1071a61..941e333 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/FEDERATION.md b/FEDERATION.md index dc11325..a394a62 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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}`, diff --git a/PrivaPub.Tests/Federation/MoveTests.cs b/PrivaPub.Tests/Federation/MoveTests.cs new file mode 100644 index 0000000..848a6f1 --- /dev/null +++ b/PrivaPub.Tests/Federation/MoveTests.cs @@ -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 Stored(RemoteActor actor) => + DB.Default.Find().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)); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 2d6ab22..dcd912a 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -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.Instance, Ledger, Local); diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 8298152..fefa922 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -32,6 +32,7 @@ public string HeaderDescription { get; set; } = string.Empty; public List 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 diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 9739267..d2021d4 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -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; } diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index 18825d7..221606e 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -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 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 Emojis { get; init; } = new(); public Dictionary 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 Uris(JsonElement value) => (value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToList() : new List { 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?), diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index 547f60c..80580a5 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -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) diff --git a/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs b/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs new file mode 100644 index 0000000..ae62bf3 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs @@ -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().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token); + Arrival.Accept("moved"); + } + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index b10cb3b..24227a0 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -94,6 +94,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index d27f0a7..e9cf1b5 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -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 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; diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 6120de0..d86748e 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -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). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7e026a4..bbe8315 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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; diff --git a/tools/pasture/scenarios/moves.sh b/tools/pasture/scenarios/moves.sh new file mode 100644 index 0000000..54aaec7 --- /dev/null +++ b/tools/pasture/scenarios/moves.sh @@ -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 : 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"