feat(federation): federate avatar changes (Update{Person} + versioned icon) - #1053
Merged
Conversation
… icon URL)
Fredrik's avatar never updated on mas.to: Mastodon copies a remote
avatar once and re-downloads only on an incoming actor Update or a
changed icon URL — Aurboda produced neither signal (stable
/u/x/avatar.png URL, no Update delivery anywhere).
- actor icon URL now carries ?v=<avatar updated_at> (new lean
getProfileAvatarVersion; identicon fallback stays unversioned)
- Person building extracted to buildActorPerson, shared by the actor
dispatcher and the new deliverActorUpdate (Update{Person} to accepted
followers), so the Update embeds exactly what the actor URL serves
- avatar upload/delete fire onAvatarChanged → deliverActorUpdate
(fire-and-forget; delivery failure never fails the profile change)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT
fiddur
marked this pull request as ready for review
August 21, 2026 17:19
fiddur
commented
Aug 21, 2026
fiddur
left a comment
Owner
Author
There was a problem hiding this comment.
✅ Approved
Solid, well-scoped slice. Extracting the actor Person into buildActorPerson and reusing it for both the dispatcher and the Update{Person} payload is the right call — the integration test that asserts the built document equals the served one locks that invariant down, which is exactly what would otherwise drift. I traced the pieces that could actually break:
?v=versioning is correct across every transition: identicon → first upload adds the param, re-upload changes it, and removal also changes it (from?v=Tback to the bare URL), so Mastodon-classavatar_remote_urlcomparisons see a diff in all three directions.onAvatarChangedis called afterupsertProfileAvatarresolves, sodeliverActorUpdatere-reads a committedupdated_at— no race where followers get an Update carrying the previous version.deliverActorUpdatematches the existingdeliver.tsfan-out pattern exactly (createContext(origin)→sendActivity({identifier}, 'followers', …)), andsetFollowersDispatcheris registered and returns accepted-only followers, so the recipient enumeration is real. Delivery is synchronous (no message queue), but the call site is fire-and-forget with a.catch, so a dead follower inbox can't slow or fail the avatar upload.buildActorPersonkeeps theisMissingDatabase→nullguard before any other query, so the addedgetProfileAvatarVersioncall can't turn a missing user DB into a 500 on the public actor endpoint.
CI is green and nothing here needs to change before merge.
Non-blocking — fold into a later PR, don't re-roll this one
Please do not push fixes for these on this PR; a round for any of them costs more than it saves. Batch them with other work:
DELETE /profile/avatarfederates even when nothing was deleted.deleteProfileAvatarreturns a boolean (andprofile-avatar.integration.test.tsasserts it reportsfalsefor a no-op), but the router discards it and callsonAvatarChangedunconditionally — so a delete with no avatar present fans anUpdate{Person}out to every follower for no change. Gating on the return value would fix it.getProfileAvatarVersionhas no case indb/profile-avatar.integration.test.ts, which CLAUDE.md asks for on every db function. The federation test does exercise it, but it uses the function to compute the expected URL while the served URL comes from the same function — so a wrong value would agree with itself and pass. Two asserts (undefinedwith no row; the value bumps on re-upload) would close the loop.- The rationale comment is duplicated five times. "Remote servers re-download only on an
Updateor a changed icon URL" now appears ingetProfileAvatarVersion's doc block,buildActorPerson's doc block and its inline avatar comment,deliverActorUpdate's doc block, theonAvatarChangedparam doc, theapi.tscall site, anddocs/features/feed.md. One or two canonical spots (the docs page +deliverActorUpdate) carry it fine. The weakest are the 6-line block on a one-query function and the inline "No?v=while the deterministic identicon is in use — the transition to a first upload changes the URL by adding one", which restates the two lines directly beneath it. - The icon-versioning test's comment overclaims: "(and each re-upload changes it again)" isn't asserted — only the first upload is. Either assert a second upload or drop the parenthetical.
- The inbound direction is still the mirror gap.
.on(Update, …)routes toingestFeedActivity, which bails on any object that isn't aNote, so anUpdate{Person}from a remote actor is dropped and our cached copies of remote handles/display names/avatars (feed_follower, timeline entries) stay stale indefinitely. Exactly the problem this PR fixes, in the other direction — worth a tracking issue.
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fredrik's avatar never refreshed when viewed from mas.to. Root cause: Mastodon-class servers copy a remote avatar once and re-download it only when the actor's icon URL changes or an
Update{Person}arrives — and Aurboda produced neither signal (stable/u/:username/avatar.pngURL; no actor-update delivery anywhere in the codebase).Changes (both halves of the standard pattern, cf. FitPub's actor-update flow):
iconnow carries the avatar'supdated_atas?v=(new leangetProfileAvatarVersion— no image bytes loaded on actor fetches). The identicon fallback stays unversioned; the first upload changes the URL by adding the param. WebFinger's avatar rel derives from the same icon, so it versions too.Update{Person}on change:Personbuilding is extracted tobuildActorPerson, shared by the actor dispatcher and the newdeliverActorUpdate, which sendsUpdate{Person}to accepted followers — an integration test asserts the Update's embedded actor equals the served actor document byte-for-byte. Avatar upload/delete fire it via a fire-and-forgetonAvatarChangedhook (a delivery failure never fails the profile change).After deploy: re-uploading the avatar (or any avatar change) pushes the refresh to followers' servers immediately; servers without a follow relationship pick the new URL up on their next actor re-fetch.
Tests: router callback matrix (upload fires, rejection doesn't, delete fires), icon-versioning integration test, Update-payload ≡ served-document equality test. Whole-monorepo
pnpm checkgreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT