- In accounts.js: all places that build an actor object from ap_followers or
ap_following docs now include `createdAt: f.createdAt || undefined`.
Previously the field was omitted, causing serializeAccount() to fall back to
`new Date().toISOString()`, making every follower/following appear to have
joined "just now" in the Mastodon client.
Affected: GET /api/v1/accounts/:id/followers, /following, /lookup, and the
resolveActorData() fallback used by GET /api/v1/accounts/:id.
- In resolve-account.js: HTTP actor URLs are now passed to lookupWithSecurity()
as a native URL object instead of a bare string, matching Fedify's preferred
type. The acct:user@domain WebFinger path remains a string (new URL() would
misparse the @ as a user-info separator under WHATWG rules).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace direct ctx.lookupObject() call in resolveRemoteAccount with
lookupWithSecurity() so servers that reject signed GETs are retried
unsigned. Also add 5 s Promise.race timeouts to followers/following/
outbox collection fetches to prevent profile loads from hanging on
slow remote servers.
Fixes missing profile pictures and zero follower stats in Mastodon
client views.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
addTimelineItem(collections, item) destructures { ap_timeline } from its
first argument. Passing collections.ap_timeline directly gave it a raw
MongoDB collection with no ap_timeline property, causing
"Cannot read properties of undefined (reading 'updateOne')".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The minimal bare JSON returned for visibility=direct DMs caused clients
(Phanpy, Elk) to show "no data" — they expect a full Mastodon Status
entity. Fix: build a proper ap_timeline document, store it with
visibility=direct (home/public timelines already exclude direct items),
and serialize it via serializeStatus() before returning. Also store the
DM in ap_notifications for the thread view as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Intercept visibility="direct" in POST /api/v1/statuses before the
Micropub pipeline. Resolve the @mention via WebFinger, build a
Create/Note AP activity addressed only to the recipient (no public
addressing), send via ctx.sendActivity(), and store in ap_notifications
for the DM thread view. No blog post is created.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
resolveAuthor() called collections.get("ap_timeline") assuming a Map, but
the Mastodon Client API passes collections as a plain object
(req.app.locals.mastodonCollections). This caused "collection.get is not a
function" on every favourite/reblog action from Mastodon clients (Phanpy,
Elk, etc.). Now checks typeof collections.get before deciding which access
pattern to use.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tags.pub's activitypub-bot (activitystrea.ms parser) rejects any activity
body containing the https://w3id.org/identity/v1 JSON-LD context with
400 Invalid request body. Fedify 2.0 adds this context via LD Signatures
(RsaSignature2017) on all outbound activities.
Workaround: lib/direct-follow.js sends Follow/Undo(Follow) with a minimal
body (no LD Sig, no proof) using draft-cavage HTTP Signatures, scoped only
to tags.pub via DIRECT_FOLLOW_HOSTS set.
Also removes [federation-diag] inbox POST logging (no longer needed).
Upstream: https://github.com/social-web-foundation/tags.pub/issues/10
tags.pub's activitypub-bot (activitystrea.ms parser) rejects any activity
body containing the https://w3id.org/identity/v1 JSON-LD context with
400 Invalid request body. Fedify 2.0 adds this context via LD Signatures
(RsaSignature2017) on all outbound activities.
Workaround: lib/direct-follow.js sends Follow/Undo(Follow) with a minimal
body (no LD Sig, no proof) using draft-cavage HTTP Signatures, scoped only
to tags.pub via DIRECT_FOLLOW_HOSTS set.
Also removes [federation-diag] inbox POST logging (no longer needed).
Upstream: https://github.com/social-web-foundation/tags.pub/issues/10
- fix: serve AP JSON for actor URLs without explicit text/html Accept header
- fix: remove RSA Multikey from assertionMethod to fix tags.pub signature verification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Fedify 2.0 migration added assertionMethods = keyPairs.map(k => k.multikey),
which places the RSA Multikey (id: #main-key) into assertionMethod alongside the
Ed25519 Multikey (id: #key-2).
This creates a keyId collision: the RSA CryptographicKey in publicKey and the RSA
Multikey in assertionMethod both use #main-key. Servers that traverse JSON-LD
properties alphabetically (assertionMethod before publicKey) find the Multikey
first — which lacks publicKeyPem — and return "public key not found".
Fix: filter assertionMethods to only Ed25519 keys (Object Integrity Proofs).
RSA keys already have their correct representation in publicKey (HTTP Signatures).
This matches Mastodon's behavior and is semantically correct per the two key systems.
Fedify's acceptsJsonLd() returns false for Accept: */* or no Accept header
because it only checks for explicit application/activity+json in the list.
Remote servers fetching actor URLs for HTTP Signature verification (e.g.
tags.pub) often omit Accept or use */*, getting HTML back instead of the
actor JSON and causing "public key not found" failures.
Add middleware to upgrade ambiguous Accept headers to application/activity+json
for GET requests to /users/:id paths. Explicit text/html requests (browsers)
are unaffected.
Also fix followActor() storing inbox: "" for actors where Fedify uses
remoteActor.inboxId?.href (not remoteActor.inbox?.id?.href). The inbox URL
is stored correctly now for all actor types.
The Fedify 2.0 migration added assertionMethods = keyPairs.map(k => k.multikey),
which places the RSA Multikey (id: #main-key) into assertionMethod alongside the
Ed25519 Multikey (id: #key-2).
This creates a keyId collision: the RSA CryptographicKey in publicKey and the RSA
Multikey in assertionMethod both use #main-key. Servers that traverse JSON-LD
properties alphabetically (assertionMethod before publicKey) find the Multikey
first — which lacks publicKeyPem — and return "public key not found".
Fix: filter assertionMethods to only Ed25519 keys (Object Integrity Proofs).
RSA keys already have their correct representation in publicKey (HTTP Signatures).
This matches Mastodon's behavior and is semantically correct per the two key systems.
Fedify's acceptsJsonLd() returns false for Accept: */* or no Accept header
because it only checks for explicit application/activity+json in the list.
Remote servers fetching actor URLs for HTTP Signature verification (e.g.
tags.pub) often omit Accept or use */*, getting HTML back instead of the
actor JSON and causing "public key not found" failures.
Add middleware to upgrade ambiguous Accept headers to application/activity+json
for GET requests to /users/:id paths. Explicit text/html requests (browsers)
are unaffected.
Also fix followActor() storing inbox: "" for actors where Fedify uses
remoteActor.inboxId?.href (not remoteActor.inbox?.id?.href). The inbox URL
is stored correctly now for all actor types.
- isTagFollowed() now checks doc?.followedAt instead of !!doc, so it
correctly returns false for global-only follows (document exists but
no local followedAt)
- getTagsPubActorUrl() strips leading # so URLs like ?tag=%23indieweb
don't produce invalid https://tags.pub/user/#indieweb actor URLs
- Remove stale "Task 5" plan reference comment in tag timeline template
- Add setGlobalFollow/removeGlobalFollow/getFollowedTagsWithState to
followed-tags storage; unfollowTag now preserves global follow state
- Add followTagGloballyController/unfollowTagGloballyController that
send AP Follow/Undo via Fedify to tags.pub actor URLs
- Register POST /admin/reader/follow-tag-global and unfollow-tag-global
routes with plugin reference for Fedify access
- Tag timeline controller passes isGloballyFollowed + error query param
- Tag timeline template adds global follow/unfollow buttons with globe
indicator and inline error display
- Wire GET /api/v1/followed_tags to return real data with globalFollow state
- Add i18n keys: followGlobally, unfollowGlobally, globallyFollowing,
globalFollowError
- isTagFollowed() now checks doc?.followedAt instead of !!doc, so it
correctly returns false for global-only follows (document exists but
no local followedAt)
- getTagsPubActorUrl() strips leading # so URLs like ?tag=%23indieweb
don't produce invalid https://tags.pub/user/#indieweb actor URLs
- Remove stale "Task 5" plan reference comment in tag timeline template
- Add setGlobalFollow/removeGlobalFollow/getFollowedTagsWithState to
followed-tags storage; unfollowTag now preserves global follow state
- Add followTagGloballyController/unfollowTagGloballyController that
send AP Follow/Undo via Fedify to tags.pub actor URLs
- Register POST /admin/reader/follow-tag-global and unfollow-tag-global
routes with plugin reference for Fedify access
- Tag timeline controller passes isGloballyFollowed + error query param
- Tag timeline template adds global follow/unfollow buttons with globe
indicator and inline error display
- Wire GET /api/v1/followed_tags to return real data with globalFollow state
- Add i18n keys: followGlobally, unfollowGlobally, globallyFollowing,
globalFollowError
Implement the Mastodon Client REST API (/api/v1/*, /api/v2/*) and OAuth2
server within the ActivityPub plugin, enabling Mastodon-compatible clients
to connect to our Fedify-based server.
Core features:
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
- HTML+JS redirect for native app custom URI schemes (Android WebView fix)
- Instance info + nodeinfo for client discovery
- Account lookup/search with remote WebFinger resolution via Fedify
- Home/public/hashtag timelines with published-date cursor pagination
- Status creation via Micropub pipeline with URL linkification and @mention extraction
- Favourite, boost, bookmark interactions with AP federation
- Notifications with type filtering and pagination
- Thread context (ancestors + descendants)
- Remote profile resolution with follower/following/post counts from AP collections
- Account stats enrichment in timeline responses (for Phanpy)
- In-memory account stats cache (500 entries, 1h TTL)
- Domain blocks API and moderation data in federation admin page
- Centralized unsigned fallback in lookupWithSecurity for servers rejecting signed GETs
- Timeline backfill from posts collection with content synthesis for bookmarks/likes/reposts
- 25+ stub endpoints preventing client errors on unimplemented features
Tested with: Phanpy (web), Elk (web), Moshidon (Android), Fedilab (Android)
All five 3.7.x releases published 2026-03-21 in one pass.
Changes from upstream:
- lib/lookup-helpers.js: lookupWithSecurity → async with signed→unsigned
fallback (handles servers like tags.pub that return 400 on signed GETs)
- lib/mastodon/helpers/account-cache.js: add reverse lookup map
(hashId → actorUrl) populated by cacheAccountStats(); export
getActorUrlFromId() for follow/unfollow resolution
- lib/mastodon/helpers/enrich-accounts.js: NEW — enrichAccountStats()
enriches embedded account objects in serialized statuses with real
follower/following/post counts; Phanpy never calls /accounts/:id so
counts were always 0 without this
- lib/mastodon/routes/timelines.js: call enrichAccountStats() after
serialising home, public, and hashtag timelines
- lib/mastodon/routes/statuses.js: processStatusContent() linkifies bare
URLs and converts @user@domain mentions to <a> links; extractMentions()
builds mention list; date lookup now tries both .000Z and bare Z suffixes
- lib/mastodon/routes/stubs.js: /api/v1/domain_blocks now returns real
blocked-server hostnames from ap_blocked_servers instead of []
- lib/mastodon/routes/accounts.js: /accounts/relationships computes
domain_blocking using ap_blocked_servers; resolveActorUrl() falls back
to getActorUrlFromId() cache for timeline-author resolution
- lib/controllers/federation-mgmt.js: fetch blocked servers, blocked
accounts, and muted accounts in parallel; pass to template
- views/activitypub-federation-mgmt.njk: add Moderation section showing
blocked servers, blocked accounts, and muted accounts
- package.json: bump version 3.6.8 → 3.7.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Federation admin page (/admin/federation): new Moderation section
showing blocked servers (with hostnames), blocked accounts, and
muted accounts/keywords
2. GET /api/v1/domain_blocks: returns actual blocked server hostnames
from ap_blocked_servers (was stub returning [])
3. Relationship responses: domain_blocking field now checks if the
account's domain matches a blocked server hostname (was always false)
Some servers (e.g., tags.pub) return 400 for signed GET requests.
Previously only followActor had an unsigned fallback — all other
callers (resolve, unfollowActor, profile viewer, messages, post
detail, OG unfurl) would silently fail.
Fix: moved the fallback logic into lookupWithSecurity itself. When
an authenticated documentLoader is provided and the lookup fails,
it automatically retries without the loader (unsigned GET). This
fixes ALL AP resolution paths in one place — resolve, follow,
unfollow, profile viewing, message sending, quote fetching.
Removed individual fallbacks in followActor and resolve controller
since the central helper now handles it.
Mastodon clients send plain text — the server must convert bare URLs
and @user@domain mentions into HTML links. Previously, URLs appeared
as plain text and mentions were not stored as mention objects.
- Bare URLs (http/https) are wrapped in <a> tags
- @user@domain patterns are converted to profile links with h-card markup
- Mentions are extracted into the mentions[] array with name and URL
- Only processes content that doesn't already contain <a> tags
(avoids double-linkifying Micropub-rendered content)
Some servers (e.g., tags.pub relay) reject or mishandle HTTP-signed GET
requests during actor resolution. The authenticated document loader is
tried first (required by Authorized Fetch servers like hachyderm.io),
then falls back to unsigned fetch if it returns null.
Same pattern should apply to unfollowActor.
POST /accounts/:id/follow returned 404 for actors resolved via Fedify
(like @_followback@tags.pub) because resolveActorUrl only checked local
data (followers/following/timeline). These actors aren't in local
collections — they were resolved on-demand via WebFinger.
Fix: add reverse lookup map (accountId hash → actorUrl) to the account
cache. When resolveRemoteAccount resolves a profile, the hash-to-URL
mapping is stored alongside the stats. resolveActorUrl checks this
cache before scanning local collections.
findTimelineItemById decoded the cursor (ms-since-epoch) back to an ISO
date via toISOString() which produces "2026-03-21T15:33:50.000Z". But
the stored published dates lack the .000Z milliseconds suffix — they're
"2026-03-21T15:33:50Z". The exact string match failed for every single
status, breaking /statuses/:id, /statuses/:id/context, and all
interaction endpoints (favourite, boost, bookmark, delete).
Fix: try both formats — with .000Z first, then without.
Phanpy never calls /accounts/:id for timeline authors — it trusts the
embedded account object in each status. These showed 0 counts because
timeline author data doesn't include follower stats.
Fix: after serializing statuses, batch-resolve unique authors that have
0 counts via Fedify AP collection fetch (5 concurrent). Results are
cached (1h TTL) so subsequent page loads are instant.
Applied to all three timeline endpoints (home, public, hashtag).
Phanpy never calls /accounts/:id for timeline authors — it uses the
embedded account object from the status response. These had 0 counts
because the timeline author data doesn't include follower stats.
Fix: in-memory LRU cache (500 entries, 1h TTL) stores account stats
from remote resolutions. serializeAccount() reads from cache when
the actor has 0 counts, enriching embedded accounts with real data.
Cache is populated by resolveRemoteAccount() (lookup, search, and
/accounts/:id calls). Once a profile has been viewed once, all
subsequent status embeds for that author show real counts.
Two bugs causing profile counts to show 0 in Phanpy:
1. Route ordering: /accounts/relationships and /accounts/familiar_followers
were defined AFTER /accounts/:id. Express matched "relationships" as
the :id parameter, returning 404. Moved them before the :id catch-all.
2. /accounts/:id only used local data (followers/following/timeline) which
has no follower counts. Now tries remote actor resolution via Fedify
to get real counts from AP collection totalItems.
Extract followers/following/statuses counts from AP collection
totalItems, profile fields from actor attachments, and published
date from the actor document. Previously showed 0/0/0 and today's
date for all remote profiles.
Account lookup (/api/v1/accounts/lookup) and search (/api/v2/search)
now resolve remote actors via Fedify's ctx.lookupObject() when not
found locally. Previously only checked ap_followers — missed accounts
we follow, timeline authors, and any remote actor.
Lookup chain: local profile → followers → following → timeline authors
→ remote WebFinger+actor fetch (Fedify)
Search uses remote resolution when resolve=true and query contains @.
1. Empty content on bookmarks/likes/reposts: synthesize content from
the interaction target URL (bookmark-of, like-of, repost-of) when
the post has no body text
2. Hashtags not extracted: parse #hashtag patterns from content text
and merge with explicit categories. Applies to both backfill
(startup) and POST /api/v1/statuses (runtime)
3. Hashtag links rewritten: /categories/tag/ links (site-internal)
are rewritten to /tags/tag (Mastodon convention) in the HTML
content stored in ap_timeline
4. Relative media URLs resolved: photo/video/audio URLs like
media/photos/... are resolved to absolute URLs using the site URL
Android Chrome Custom Tabs block 302 redirects to custom URI schemes
(fedilab://, moshidon-android-auth://) for security. The server sends
the redirect correctly but the WebView silently ignores it — "nothing
happens" when the user taps Authorize.
Fix: detect non-HTTP redirect URIs and render an HTML page with both
a JavaScript window.location redirect and a meta refresh fallback.
Client-side navigation to custom schemes is allowed by WebViews.
HTTP(S) redirect URIs (Phanpy, Elk) still use standard 302.
Five improvements to strict ActivityPub protocol compliance and
real-world Mastodon interoperability:
1. allowPrivateAddress: true in createFederation (federation-setup.js)
Fixes Fedify's SSRF guard rejecting own-site URLs that resolve to
private IPs on the local LAN (e.g. home-network deployments where
the blog hostname maps to 10.x.x.x internally).
2. Canonical id on Like activities (jf2-to-as2.js)
Per AP §6.2.1, activities SHOULD have an id URI so remote servers
can dereference them. Derives mount path from actor URL and constructs
{publicationUrl}{mount}/activities/like/{post-path}.
3. Like activity object dispatcher (federation-setup.js)
Per AP §3.1, objects with an id MUST be dereferenceable at that URI.
Registers federation.setObjectDispatcher(Like, .../activities/like/{+id})
so fetching the canonical Like URL returns the activity as AP JSON.
Adds Like to @fedify/fedify/vocab imports.
4. Repost commentary in AP output (jf2-to-as2.js)
- jf2ToAS2Activity: only sends Announce for pure reposts (no content);
reposts with commentary fall through to Create(Note) with content
formatted as "{commentary}<br><br>🔁 <url>" so followers see the text.
- jf2ToActivityStreams: prepends commentary to the repost Note content
for correct display in content-negotiation / search responses.
5. GET /api/ap-url public endpoint (index.js)
Resolves a blog post URL → its Fedify-served AP object URL for use by
"Also on Fediverse" widgets. Prevents nginx from intercepting
authorize_interaction requests that need AP JSON.
Special case: AP-likes return { apUrl: likeOf } so authorize_interaction
opens the original remote post rather than the blog's like post.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Mastodon API timeline sorted by MongoDB _id (insertion order), not
by published date. This caused chronological jumps — backfilled or
syndicated posts got ObjectIds at import time, interleaving them
incorrectly with federation-received posts.
Changes:
- Pagination cursors now use published date (encoded as ms-since-epoch)
instead of ObjectId. Mastodon clients pass these as opaque max_id/
min_id/since_id values and they sort correctly.
- Status and notification IDs are now encodeCursor(published) so the
cursor round-trips through client pagination.
- Status lookups (GET/DELETE /statuses/:id, context, interactions) use
findTimelineItemById() which tries published-based lookup first, then
falls back to ObjectId for backwards compatibility.
- Link pagination headers emit published-based cursors.
This matches the native reader's sort (storage/timeline.js) which has
always sorted by published: -1.
MongoDB sparse indexes skip documents where the indexed field is ABSENT,
but still enforce uniqueness on explicit null values. The auth code insert
set accessToken:null and the client_credentials insert set code:null,
causing E11000 duplicate key errors on the second authorization attempt.
Fix: omit accessToken/code entirely from inserts where they don't apply.
The field gets added later during token exchange ($set in updateOne).
The fallback avatar URL pointed to /placeholder-avatar.png which doesn't
exist (404). Changed to /images/default-avatar.svg which exists in the
Eleventy theme and is served by the nginx image caching location with
CORS headers — fixing cross-origin errors in Phanpy/Elk.