README.md: 18-row table covering core protocols (ActivityPub, HTTP
Signatures, RFC 9421, WebFinger, NodeInfo) and 11 FEPs with status
and provider attribution (Fedify vs Plugin).
CLAUDE.md: developer-facing 11-row FEP table with implementation
file locations for each standard.
- Provide content as {text, html} with linkified URLs (Micropub's
markdown-it doesn't have linkify enabled)
- Use content-warning field (not summary) to match native reader and
AP syndicator expectations
- Remove premature addTimelineItem — post appears in timeline after
syndication round-trip, not immediately
- Remove processStatusContent (unused after addTimelineItem removal)
- Remove addTimelineItem import
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.
The extracted apCardInteraction component read data-mount-path,
data-csrf-token, and data-item-uid from this.$el inside interact(),
but $el may not be the x-data root when called from a child button
click. The old inline code used this.$root. Fixed by reading all
data attributes in init() and storing as component properties.
CSS @import would cause 15 extra HTTP requests per page load.
Indiekit serves plugin assets via express.static which doesn't
process @import. Keep assets/css/*.css as source of truth for
development, concatenate into reader.css for serving.
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>
URLs at the end of a sentence followed by . , ; : ) etc. were capturing
the punctuation character as part of the URL, producing broken links
(e.g. https://example.com. instead of https://example.com).
Fix in both places where URL linkification happens:
- lib/jf2-to-as2.js linkifyUrls() — used when federating posts via AP
- lib/mastodon/routes/statuses.js processStatusContent() — used when
creating posts via the Mastodon Client API
Both now use a replacement callback that strips trailing [.,;:!?)\]'"]
from the captured URL before inserting it into the <a> tag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the Mastodon Client API edit endpoint, which was returning 501 so
"Beitrag bearbeiten" / edit post always failed.
Flow:
1. Look up timeline item by cursor ID; 403 if not own post
2. Build Micropub replace operation for content/summary/sensitive/language
and call postData.update() + postContent.update() to update MongoDB
posts collection and the content file on disk
3. Patch ap_timeline with new content, summary, sensitive, and updatedAt
(serializeStatus reads updatedAt → edited_at field)
4. Broadcast AP Update(Note) to all followers via shared inbox so remote
servers can display the edit indicator
5. Return serialized status with edited_at set
Also adds Update to the top-level @fedify/fedify/vocab import and updates
the module-level comment block to list the new route.
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>
findTimelineItemById's string range query used $gte/$lte on ISO strings,
which fails for items stored with timezone offsets (e.g. "+01:00"): the
string "2026-03-21T16:33:50+01:00" is lexicographically outside the
range ["2026-03-21T15:33:50Z", "2026-03-21T15:33:51Z"].
Replace with a $or that covers both cases:
- BSON Date stored (Micropub): direct Date range comparison
- String stored with any timezone: $dateFromString parses the offset
correctly, $toLong converts to ms-since-epoch, numeric range always
matches regardless of timezone format
Items received before extractObjectData's UTC normalization (a259c79)
was deployed are stored with the original server's timezone offset and
now resolve correctly through this fallback.
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>
likePost, unlikePost and boostPost all call resolveAuthor() which makes
up to 3 signed HTTP requests to the remote server (post fetch, author
actor fetch, getAttributedTo) with no timeout. If the remote server is
slow or unreachable, the favourite/reblog HTTP response hangs until the
Node.js socket default times out (~2 min). Mastodon clients (Phanpy,
Elk) give up much sooner and show "Failed to load post".
Fix: wrap every resolveAuthor() call in a Promise.race() with a 5 s
timeout. The interaction is still recorded in ap_interactions and the
Like/Announce activity is still sent when recipient resolution succeeds
within the window; if it times out, AP delivery is silently skipped
(the local record is kept — the client sees a successful ⭐).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two profile display fixes:
1. Avatar not persisting across requests: resolveRemoteAccount fetches
the correct avatar via lookupWithSecurity, but only updated the
in-memory serialized status — never the DB or the cache. On the next
request serializeStatus rebuilt the account from item.author.photo
(empty if the actor was on a Secure Mode server when the item arrived),
and enrichAccountStats skipped re-fetching because follower counts
were already > 0. Fix: include avatarUrl in cacheAccountStats; in
collectAccount always check the cache first (for avatar + createdAt)
regardless of whether counts are already populated.
2. actor.published may not be UTC: Temporal.Instant.toString() preserves
the original timezone offset from the AP actor object; wrap in
new Date(...).toISOString() so created_at is always UTC ISO 8601.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three-layer fix for findTimelineItemById cursor mismatches:
1. encodeCursor returns "" (falsy) for invalid dates — serializeStatus
now correctly falls back to item._id.toString() instead of using "0"
as an opaque ID that can never be looked up.
2. findTimelineItemById adds two new fallbacks after the existing string
lookups fail:
- BSON Date lookup: Micropub pipeline (postData.create) may store
published as a JavaScript Date → MongoDB BSON Date; string
comparison never matches.
- ±999 ms ISO range query: some AP servers send published with a
timezone offset (e.g. +01:00). String(Temporal.Instant) preserves
the original offset; decodeCursor normalizes to UTC, so the stored
string and the lookup string differ.
3. timeline-store.js extractObjectData now normalizes published via
new Date(String(...)).toISOString() before storing, ensuring all
future items are stored in UTC ISO format and the exact-match lookup
succeeds without needing the range fallback.
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.
- 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