Reverts the M7 nav reduction — the audit recommended reducing to 3,
but Notifications, Messages, Moderation, and My Profile need to be
directly accessible from the sidebar for usability.
After a Micropub post is syndicated via Create activity, insert it into
ap_timeline so the Mastodon Client API can resolve it by ID or ObjectId.
This fixes 404s on /api/v1/statuses/:id/context for website-authored posts
(replies, notes, bookmarks) that were previously missing from ap_timeline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the Mastodon Client API endpoints for pinning and unpinning posts:
- POST /api/v1/statuses/:id/pin — upserts a document into ap_featured
(same store the admin UI uses), enforces the 5-post maximum, fires
broadcastActorUpdate() so remote servers re-fetch the featured collection
- POST /api/v1/statuses/:id/unpin — removes from ap_featured, broadcasts update
- loadItemInteractions() now also queries ap_featured and returns pinnedIds
- GET /api/v1/statuses/:id response now reflects actual pin state
- broadcastActorUpdate wired into mastodon pluginOptions in index.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merges upstream fix that adds Strategy 1b to resolveAuthor: a raw signed
HTTP fetch for servers (e.g. wafrn) that return AP JSON without @context,
which Fedify's JSON-LD processor would otherwise reject.
Combined with our 5-second timeout wrapper so both improvements apply:
- privateKey/keyId now passed to resolveAuthor for the signed raw fetch
- timeout still guards all three strategies against slow/unreachable remotes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jf2ToAS2Activity() always returns a Create activity. For post edits,
Fediverse servers expect an Update wrapping the updated object, not a
second Create. Extract the Note/Article from the Create via getObject(),
then wrap it in new Update({ actor, object: note }) — matching the
existing broadcastActorUpdate() pattern exactly.
Remove the fragile middleware in contentNegotiationRoutes that wrapped
res.json to detect successful Micropub delete responses. Replace it with
clean delete() and update() lifecycle methods on ActivityPubEndpoint that
are called directly by post-content.js via callSyndicatorHook.
Also adds broadcastPostUpdate() to send Update activities for edited posts,
mirroring the broadcastDelete() batch-delivery pattern.
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.
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
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.
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.
- 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
- 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
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.
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.
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>
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>
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
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
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>
- Outbound Delete: broadcastDelete() + POST /admin/federation/delete route
- Visibility: unlisted + followers-only addressing via defaultVisibility config
- Content Warning: outbound sensitive flag + summary as CW text
- Polls: inbound Question/poll parsing with progress bar rendering
- Flag: inbound report handler with ap_reports collection + Reports tab
- Includes DM support files from v2.9.x (messages controller, storage, templates)
- Includes coverage audit and high-impact gaps implementation plan
Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83
federation-setup.js:
- Suppress fedify docloader logs below fatal level to reduce noise from
deleted remote actors (404/410)
- Add visibility:unlisted guard to outbox dispatcher, counter, and
resolvePost object dispatcher
controllers/migrate.js:
- Allow clearing alsoKnownAs by detecting submitted empty aliasUrl field
via hasOwnProperty check (previously only set when non-empty)
index.js:
- Add resolveAuthor import
- Skip federation for unlisted posts in syndicate()
- Add likePost(postUrl, collections) — sends AP Like activity to author
- Add boostPost(postUrl, collections) — sends AP Announce to followers
and directly to the post author's inbox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the quick-reply code path entirely — all replies now go through
Micropub as blog posts. Quick replies created orphan URLs that served
raw JSON-LD to browsers and caused unreadable links in conversations.
- Delete quick-reply controller (note-object.js) and route
- Remove ap_notes collection registration
- Simplify compose form: no mode toggle, no character counter
- Remove quick-reply CSS and locale strings
Confab-Link: http://localhost:8080/sessions/d116ad5b-ef8a-424e-9ebe-76c06bef1df6
The non-async init() fired dropIndex and createIndex concurrently,
causing MongoDB to abort the index build (IndexBuildAborted error 276).
Chain createIndex via .then() so it runs after the drop completes.
Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
- Poll every 30s for new items, show sticky "N new posts — Load" banner
- IntersectionObserver marks cards as read at 50% visibility, batches to
server every 5s
- Read cards fade to 70% opacity, full opacity on hover
- "Unread" toggle in tab bar filters to unread-only items
- New API: GET /api/timeline/count-new, POST /api/timeline/mark-read
Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
Replace the cramped deck/column layout on the explore page with a
tabbed interface. Three tab types: Search (always first), Instance
(pinned with local/federated badge), and Hashtag (aggregated across
all pinned instances).
- New ap_explore_tabs collection replaces ap_decks (clean start)
- Tab CRUD API: add, remove, reorder with CSRF/SSRF validation
- Per-tab infinite scroll with IntersectionObserver + AbortController
- Hashtag tabs query up to 10 instances in parallel, merge by date,
deduplicate by URL
- WAI-ARIA tabs pattern with arrow key navigation
- LRU cache (5 tabs) for tab content
- Extract shared explore-utils.js (validators + status mapping)
- Remove all old deck code (JS, CSS, controllers, locale strings)
Users can favorite instances (with local or federated scope) as persistent
columns in a multi-column deck view. Each column streams its own public
timeline with independent infinite scroll. Includes two-tab explore UI
(Search + Decks), deck CRUD API with CSRF/SSRF protection, 8-deck limit,
responsive CSS Grid layout, and scope badges.
Register resolveActorAvatar() on Indiekit.config.application during
init(). Uses Fedify's authenticated document loader to fetch actor
profiles from servers with Authorized Fetch enabled (e.g., hachyderm.io,
indieweb.social). Called by the conversations plugin's avatar backfill.
- Add FediDB API client (lib/fedidb.js) with MongoDB caching (24h TTL)
for instance search, timeline support checks, and popular accounts
- Explore page: instance input now shows autocomplete suggestions from
FediDB with software type, MAU count, and timeline support indicator
(checkmark/cross) via background pre-check
- Reader page: @handle lookup input now shows popular fediverse accounts
from FediDB with avatar, name, handle, and follower count
- Three new API endpoints: /api/instances, /api/instance-check,
/api/popular-accounts
- Alpine.js components for both autocomplete UIs with keyboard navigation
- Fix mentions/hashtags bug: separate Fedify Mention and Hashtag types into
distinct mentions[] and category[] arrays with proper @ and # rendering
- Add hashtag timeline filtering at /admin/reader/tag with regex-safe queries
- Replace prev/next pagination with AlpineJS infinite scroll (IntersectionObserver)
with no-JS fallback pagination preserved
- Add public instance timeline explorer at /admin/reader/explore with SSRF
prevention and XSS sanitization via Mastodon-compatible API
- Add hashtag following with ap_followed_tags collection, inbox listener
integration for non-followed accounts, and followed tags sidebar display
- Include one-time migration script for legacy timeline data
Add OStatus subscribe template to WebFinger responses so remote servers
(WordPress AP, Misskey, etc.) can discover and redirect users to complete
follow interactions. Unauthenticated users are sent to login first, then
redirected to the existing reader profile page with follow/unfollow UI.
MongoDB collections may not be available yet when init() runs if the
database connection hasn't completed. Wrap all createIndex calls in
try-catch so the plugin doesn't crash on startup. Indexes already exist
from previous runs; this is non-fatal.
dropIndex() was called with await inside the non-async init() method,
causing "Unexpected reserved word" and preventing Indiekit from starting.
Use promise .catch() instead since the result isn't needed.
Moderation page rewritten as single Alpine.js component with inline DOM
updates instead of location.reload(). Added hide/warn filter mode toggle
— warn mode shows muted items behind content warning instead of hiding.
Expanded keyword matching to check content, titles, and summaries.
Fixed MongoDB E11000 duplicate key error by dropping non-sparse indexes
on startup and recreating with sparse:true. Storage layer no longer
stores null url/keyword fields.