- 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
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>
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.
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.
The accessToken_1 unique index on ap_oauth_tokens lacked sparse:true.
During OAuth2 authorization, POST /oauth/authorize inserts a document
with accessToken:null (auth code phase — token not yet issued). MongoDB
unique indexes include null values by default, so only one such document
could exist. Every subsequent authorization attempt failed with E11000
duplicate key error.
Adding sparse:true skips null values in the index, allowing multiple
auth code documents to coexist while still enforcing uniqueness among
actual access tokens. This matches the code index pattern (line 1423)
which already uses sparse:true.
Note: existing deployments must drop the stale index before restart:
mongosh $MONGODB_URL --eval 'db.ap_oauth_tokens.dropIndex("accessToken_1")'
mongosh $MONGODB_URL --eval 'db.ap_oauth_tokens.deleteMany({accessToken:null})'
Confab-Link: http://localhost:8080/sessions/0b241cd6-aff2-4fec-853c-2b5a61e61946
When the `like-of` URL serves ActivityPub content (detected via content
negotiation with Accept: application/activity+json), deliver a proper
`Like { actor, object, to: Public }` activity to followers.
For likes of regular (non-AP) URLs, fall through to the existing
bookmark-style `Create(Note)` behaviour (🔖 content with #bookmark tag).
- Add `isApUrl()` async helper (3 s timeout, fails silently)
- Make `jf2ToAS2Activity` async; add Like detection before repost block
- Update all four call sites in federation-setup.js and index.js
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
OG images are served at /og/{slug}.png (e.g. /og/14b61.png),
not with date prefixes. Remove the date segments from the URL
construction in both jf2ToActivityStreams and jf2ToAS2Activity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Likes now shown as Create(Note) bookmark, not Like activity
- Announces use upstream addressing (to: Public, no cc)
- Document OG image support for fediverse preview cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Likes are now sent as Create/Note with bookmark-style content (🔖)
instead of Like activities, ensuring proper display on Mastodon
- Announce activities reverted to upstream addressing (to: Public only,
no cc:followers)
- Add per-post OG image to both plain JSON-LD and Fedify Note/Article
objects, derived from the post URL pattern (/og/{date}-{slug}.png)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mastodon shared inboxes require cc:followers to route activities to
local followers. Like had no to/cc at all, Announce was missing cc.
Also normalize nested tags (on/art/music → music) in hashtag names.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
Updated jf2-to-as2 and compose controller to use the renamed
"content-warning" property instead of overloading "summary" for
CW text. This pairs with the endpoint-posts fix that renamed the
CW form input to prevent collision with the summary field.
Confab-Link: http://localhost:8080/sessions/1dcdf030-8015-4d23-89da-b43fd69c7138
Deleted posts (with properties.deleted timestamp) were still served
via the outbox dispatcher and content negotiation catch-all. Now:
- Outbox find() and countDocuments() filter out deleted posts
- Object dispatcher returns null for deleted posts (Fedify 404)
- Content negotiation falls through to Express for deleted posts
Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
Fedify serializes the endpoints object with "type": "as:Endpoints" which
is not a valid ActivityStreams 2.0 type. This causes browser.pub validation
failures. Strip the type field in the JSON patching block.
Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
- Filter isContext items and private/direct posts from main timeline, new post count, and unread count
- Post detail: query local replies from ap_timeline before remote fetch, deduplicate, sort chronologically
- Add visibility badge (unlisted/private/direct) on item cards next to timestamp
Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
Add full feature documentation for federation resilience (v2.14.0) and
Hollo-inspired patterns (v2.15.0). Add credits to Hollo, Fedify, and Wafrn.
Update architecture tree, collections table, routes, and gotchas in CLAUDE.md.
Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
federation-bridge.js:
- Buffer application/activity+json and ld+json bodies that Express
doesn't parse (inbox POSTs from Mastodon, PeerTube, etc.)
- Store original bytes in req._rawBody and pass them verbatim to Fedify
so HTTP Signature Digest verification passes; JSON.stringify reorders
keys which caused every Mastodon Like/Announce/Create to be silently
rejected
- Short-circuit PeerTube View (WatchAction) activities with 200 before
Fedify's JSON-LD parser throws on Schema.org extensions
federation-setup.js:
- Accept signatures up to 12 hours old (Mastodon retries with the
original signature hours after a failed delivery)
- Look up AP object URLs with $in [url, url+"/"] to tolerate trailing
slash differences between stored posts and AP object URLs
inbox-listeners.js:
- Register a no-op .on(View) handler so Fedify doesn't log noisy
"Unsupported activity type" errors for PeerTube watch events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Integrates upstream features (visibility/CW compose controls, @mention
support, federation management page, layout fix) while preserving
svemagie DM support. Visibility and syndication controls are hidden
for direct messages.
Upstream v2.10.0 adds: outbound Delete, visibility addressing (unlisted/
followers-only), Content Warning (sensitive flag + summary), inbound poll
rendering, Flag/report handler, DM support files.
Conflict resolution — all four conflicts were additive (no code removed):
lib/controllers/reader.js: union of validTabs — fork added "mention",
upstream added "dm" and "report"; result keeps all five additions.
lib/storage/notifications.js: union of count keys — fork added mention:0,
upstream added dm:0 and report:0; result keeps the fork's mention split
logic alongside the new upstream keys.
views/partials/ap-notification-card.njk: fork kept isDirect 🔒 badge for
direct mentions; upstream added ✉ for dm and ⚑ for report; result keeps
the isDirect branch and appends the two new type badges.
package.json: upstream bumped to 2.10.0; we bump to 2.10.1 to reflect our
own Alpine.js and publication-aware docloader bug fixes on top.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- views/layouts/ap-reader.njk: Replace incorrect comment "Alpine.js
loaded by default.njk" with an actual Alpine.js CDN script tag.
Without this, all Alpine directives on the remote-profile page
(x-data, @click, x-text, :class) were dead — Follow/Mute/Block
buttons showed no label and clicks did nothing.
- lib/resolve-author.js: Add createPublicationAwareDocumentLoader()
which wraps the authenticated Fedify document loader to opt in to
allowPrivateAddress for requests to the publication's own hostname.
Fedify blocks private IP ranges by default; self-hosted instances
(localhost / private IPs) were failing author resolution for their
own posts with a private-address error. All three lookupObject calls
in resolveAuthor() now use the wrapped loader.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>