Servers like wafrn return AP JSON without @context, causing Fedify's
JSON-LD processor to reject the document. Strategy 1b in resolveAuthor
does a direct signed GET, extracts attributedTo/actor from plain JSON,
then resolves the actor via lookupWithSecurity.
Also: _loadRsaPrivateKey now imports with extractable=true (required
by Fedify's signRequest), and loadRsaKey is wired through to all
Mastodon API interaction helpers.
- 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
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)
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)
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.
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.
Own posts in ap_timeline have author.url set to the publication URL
(site root like "https://rmendes.net/") with no /@handle or /users/handle
pattern. extractUsername("/") returns "" which falls back to "unknown".
Fix: set module-level local identity (publicationUrl + handle) at plugin
init via setLocalIdentity(). serializeStatus() compares item.author.url
against the publication URL and passes isLocal:true + handle to
serializeAccount() when they match.
This is zero-cost for callers — no signature changes needed at the 20+
serializeStatus() call sites.
Implement the Mastodon Client REST API (/api/v1/*, /api/v2/*) and OAuth2
server within the ActivityPub plugin, enabling Mastodon-compatible clients
to connect to the Fedify-based server.
Core features:
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
- Instance info + nodeinfo for client discovery
- Account lookup, verification, relationships, follow/unfollow/mute/block
- Home/public/hashtag timelines with cursor-based pagination
- Status viewing, creation, deletion, thread context
- Favourite, boost, bookmark interactions with AP federation
- Notifications with type filtering and pagination
- Search across accounts, statuses, and hashtags
- Markers for read position tracking
- Bookmarks and favourites collection lists
- 25+ stub endpoints preventing client errors on unimplemented features
Architecture:
- 24 new files under lib/mastodon/ (entities, helpers, middleware, routes)
- Virtual endpoint at "/" via Indiekit.addEndpoint() for domain-root access
- CORS + JSON error handling for browser-based clients
- Six-layer mute/block filtering reusing existing moderation infrastructure
BREAKING CHANGE: bumps to v3.0.0 — adds new MongoDB collections
(ap_oauth_apps, ap_oauth_tokens, ap_markers) and new route registrations
Confab-Link: http://localhost:8080/sessions/5360e3f5-b3cc-4bf3-8c31-5448e2b23947