Skip to content

feat(federation): federate avatar changes (Update{Person} + versioned icon) - #1053

Merged
fiddur merged 1 commit into
developfrom
avatar-federation-update
Aug 21, 2026
Merged

feat(federation): federate avatar changes (Update{Person} + versioned icon)#1053
fiddur merged 1 commit into
developfrom
avatar-federation-update

Conversation

@fiddur

@fiddur fiddur commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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.png URL; no actor-update delivery anywhere in the codebase).

Changes (both halves of the standard pattern, cf. FitPub's actor-update flow):

  • Versioned icon URL: the actor document's icon now carries the avatar's updated_at as ?v= (new lean getProfileAvatarVersion — 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: Person building is extracted to buildActorPerson, shared by the actor dispatcher and the new deliverActorUpdate, which sends Update{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-forget onAvatarChanged hook (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 check green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT

… 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
fiddur marked this pull request as ready for review August 21, 2026 17:19

@fiddur fiddur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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=T back to the bare URL), so Mastodon-class avatar_remote_url comparisons see a diff in all three directions.
  • onAvatarChanged is called after upsertProfileAvatar resolves, so deliverActorUpdate re-reads a committed updated_at — no race where followers get an Update carrying the previous version.
  • deliverActorUpdate matches the existing deliver.ts fan-out pattern exactly (createContext(origin)sendActivity({identifier}, 'followers', …)), and setFollowersDispatcher is 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.
  • buildActorPerson keeps the isMissingDatabasenull guard before any other query, so the added getProfileAvatarVersion call 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:

  1. DELETE /profile/avatar federates even when nothing was deleted. deleteProfileAvatar returns a boolean (and profile-avatar.integration.test.ts asserts it reports false for a no-op), but the router discards it and calls onAvatarChanged unconditionally — so a delete with no avatar present fans an Update{Person} out to every follower for no change. Gating on the return value would fix it.
  2. getProfileAvatarVersion has no case in db/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 (undefined with no row; the value bumps on re-upload) would close the loop.
  3. The rationale comment is duplicated five times. "Remote servers re-download only on an Update or a changed icon URL" now appears in getProfileAvatarVersion's doc block, buildActorPerson's doc block and its inline avatar comment, deliverActorUpdate's doc block, the onAvatarChanged param doc, the api.ts call site, and docs/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.
  4. 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.
  5. The inbound direction is still the mirror gap. .on(Update, …) routes to ingestFeedActivity, which bails on any object that isn't a Note, so an Update{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.

@fiddur
fiddur merged commit 69f0ddd into develop Aug 21, 2026
4 checks passed
@fiddur
fiddur deleted the avatar-federation-update branch August 21, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant