Files
indiekit-server/memory/project_activitypub.md
Sven 2e35c5bd40
All checks were successful
Deploy Indiekit Server / deploy (push) Successful in 1m15s
doc: update
2026-04-01 17:42:18 +02:00

14 KiB
Raw Blame History

ActivityPub Federation — Architecture & Patch Chain

Key Files

File Role
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js AP reader compose form: GET builds the form with syndicationTargets, POST submits to Micropub
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js Mastodon-compatible API: POST /api/v1/statuses creates posts via Micropub pipeline
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js AP syndicator: federates posts to followers, adds own posts to ap_timeline
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js Converts JF2 properties to ActivityPub Note/Create activity
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/storage/timeline.js addTimelineItem() — atomic upsert ($setOnInsert) to ap_timeline
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js Serializes ap_timeline documents to Mastodon Status JSON
node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/pagination.js encodeCursor(date) / decodeCursor(id) — ms-since-epoch as status ID
node_modules/@indiekit/endpoint-micropub/lib/post-type-discovery.js getPostType() — returns "reply" if in-reply-to key present, else "note"
node_modules/@indiekit/endpoint-micropub/lib/post-data.js Micropub create pipeline: normalise → getPostType → save
node_modules/@rmdes/indiekit-endpoint-microsub/lib/controllers/reader.js Microsub reader compose: auto-selects syndication targets via detectProtocol()
node_modules/@rmdes/indiekit-endpoint-activitypub/views/activitypub-compose.njk AP reader compose template: uses target.defaultChecked for checkbox state
node_modules/@rmdes/indiekit-endpoint-activitypub/views/partials/ap-item-card.njk Timeline card: reply button passes item.url as replyTo query param

Data Flow: Reply via AP Reader UI

User clicks reply on timeline item
  → /activitypub/admin/reader/compose?replyTo=<url>
  → composeController() fetches syndicationTargets from Micropub q=config
  → sets target.defaultChecked = target.checked === true  [patch: ap-compose-default-checked]
  → renders activitypub-compose.njk with hidden in-reply-to field
  → user submits form
  → submitComposeController() POSTs to Micropub with in-reply-to
  → Micropub: formEncodedToJf2 → normaliseProperties → getPostType("reply") → save to /replies/{slug}/
  → syndication webhook fires → AP syndicator.syndicate(properties)
  → jf2ToAS2Activity creates Note with inReplyTo
  → delivered to followers + original author's inbox

Data Flow: Reply via Mastodon Client API (Phanpy/Elk)

Client sends POST /api/v1/statuses { status, in_reply_to_id }
  → findTimelineItemById(ap_timeline, in_reply_to_id)
    → decodes cursor (ms-since-epoch) → looks up by published date
  → inReplyTo = replyItem.uid || replyItem.url
  → jf2["in-reply-to"] = inReplyTo  (if resolved)
  → jf2["mp-syndicate-to"] = [publicationUrl]  (always set — AP-only)
  → postData.create → postContent.create
  → addTimelineItem immediately [patch: ap-mastodon-reply-threading]
  → returns minimal status JSON to client
  → build webhook → syndicator → AP delivery

Own Post in ap_timeline

The AP syndicator stores outbound posts with:

  • uid = url = properties.url (blog URL, e.g. https://blog.giersig.eu/replies/slug/)
  • published = properties.published (ISO 8601 with TZ offset, e.g. "2026-03-21T16:33:50+01:00")
  • inReplyTo = properties["in-reply-to"] (original Mastodon URL or own blog URL)

The Mastodon API encodes status IDs as encodeCursor(published) = ms-since-epoch string. findTimelineItemById uses a ±1 s range query with $dateFromString to handle TZ-offset strings.

Syndication Target Config

The AP syndicator's info object:

{
  checked: true,                        // from indiekit.config.mjs: checked: true
  name: `@${handle}@${hostname}`,       // e.g. "@svemagie@blog.giersig.eu"
  uid: publicationUrl,                  // e.g. "https://blog.giersig.eu/"
  service: { name: "ActivityPub (Fediverse)", ... }
}

The Micropub q=config endpoint returns this with checked: true. Both compose forms read this value:

  • Microsub reader: uses target.checked directly (always pre-checked if checked: true in config)
  • AP reader: uses target.defaultChecked set by composeController() — was broken, now fixed

Patches Applied (AP threading)

patch-ap-compose-default-checked

File: lib/controllers/compose.js Problem: target.defaultChecked was hardcoded to name === "@rick@rmendes.net" — never matches. Fix: target.defaultChecked = target.checked === true Effect: AP syndication checkbox is pre-checked in the AP reader compose form, matching the checked: true config — replies via the AP reader UI are now federated.

patch-ap-mastodon-reply-threading

File: lib/mastodon/routes/statuses.js Problem: After POST /api/v1/statuses, own post was NOT inserted into ap_timeline until the Eleventy build webhook fired (30120 s later). Follow-up replies during that window would fail findTimelineItemByIdinReplyTo = null → no in-reply-to in JF2 → getPostType returned "note" → reply saved at /notes/ with no inReplyTo in the AP activity. Fix: Eagerly insert a provisional timeline item via addTimelineItem() immediately after postContent.create(). Uses $setOnInsert (idempotent); syndicator's later upsert is a no-op. Effect: in_reply_to_id can be resolved immediately → correct "reply" post type → proper inReplyTo in the AP Note → thread displays correctly on Mastodon.

Post Type Discovery

getPostType(postTypes, properties) in post-type-discovery.js:

  • Checks propertiesMap.has("in-reply-to")"reply"
  • Checks propertiesMap.has("like-of")"like"
  • Checks propertiesMap.has("repost-of")"repost"
  • Falls through to "note" if none match

The "reply" post type in indiekit.config.mjs has no discovery field — standard PTD spec applies.

Inbound AP Activity Pipeline

Activities from remote servers follow this path:

Remote server → nginx → Express (body buffered in createFedifyMiddleware)
  → Fedify signature check (uses req._rawBody for digest)
  → Fedify Redis message queue (if Redis configured)
  → Fedify queue worker → inbox listener (inbox-listeners.js)
  → enqueueActivity() → ap_inbox_queue (MongoDB)
  → startInboxProcessor() (1s poll) → routeToHandler()
  → handleLike / handleAnnounce / handleCreate
  → addNotification() → ap_notifications

Critical: collections._publicationUrl is set in index.js (_publicationUrl: this._publicationUrl) AND by patch-ap-inbox-publication-url in federation-setup.js. Both set "https://blog.giersig.eu/".

Notification conditions gate on pubUrl && objectId.startsWith(pubUrl):

  • handleLike: only notifies for likes of our own content
  • handleAnnounce PATH 1: only notifies for boosts of our content
  • handleCreate: only notifies for replies to our posts (inReplyTo.startsWith(pubUrl))

Body buffering (createFedifyMiddleware): application/activity+json bodies are buffered into req._rawBody before express.json() (which only handles application/json) touches them. fromExpressRequest passes req._rawBody verbatim to the Fedify Request object so the HTTP Signature Digest check passes.

Fedify inbox log suppression: ["fedify","federation","inbox"] was hardcoded to "fatal" (patch-ap-inbox-delivery-debug fixes this to "error" so real failures are visible).

Diagnosing inbox delivery issues:

  • Set AP_DEBUG=1 → logs [AP-inbox] POST /activitypub/users/svemagie/inbox ct=... body=...B BEFORE Fedify's signature check. If this doesn't appear, activities aren't reaching our server.
  • With inbox log level now "error": signature failures show as Fedify error logs.
  • Queue processing failures: [inbox-queue] Failed processing ... — always logged.

patch-ap-signature-host-header (2026-04-01)

File: lib/controllers/federation-bridge.jsfromExpressRequest() Problem: patch-ap-federation-bridge-base-url fixed Fedify URL routing to use the canonical publicationUrl, but left the host header in the copied Headers object untouched. nginx forwards an internal host (e.g. 10.100.0.20) which Fedify reads from request.headers.get("host") when reconstructing the signed-string for Cavage HTTP Signatures. Signed-string mismatch → every inbox POST returns 401 → remote servers exhaust retries and stop delivering. Fix: After the header-copy loop in fromExpressRequest(), override "host" with new URL(publicationUrl).host ("blog.giersig.eu") when publicationUrl is provided. Effect: HTTP Signature verification now succeeds for all inbound AP activities.

patch-ap-mastodon-status-id (2026-04-01)

File: lib/mastodon/routes/statuses.js Problem: POST /api/v1/statuses returned id: String(Date.now()) — the wall-clock time of the response. The ap_timeline item uses published: data.properties.published, set before the Gitea write (which can take 515 s). When the client replies to the freshly created post, it sends in_reply_to_id: <Date.now() id>, which is 515 s later than the stored published → the ±1 s range query misses → inReplyTo = null → reply saved as note. Fix: Use encodeCursor(data.properties.published) as the status ID in the creation response (falls back to String(Date.now()) if published is missing). Response ID now matches what findTimelineItemById will resolve.

patch-ap-interactions-send-guard (2026-04-01)

File: lib/mastodon/helpers/interactions.js Problem: likePost and boostPost call ctx.sendActivity(...) without try/catch. Any Fedify or Redis error propagates → 500 response → the ap_interactions DB write never runs → interaction not recorded locally. Fix: Wrap both sendActivity calls in try/catch so delivery failures are non-fatal. Interaction still recorded in ap_interactions; client sees correct UI state.

patch-ap-syndicate-dedup (2026-04-01)

File: lib/syndicator.jssyndicate() Problem: The CI webhook calls /syndicate?source_url=X&force=true after every Eleventy build. When syndicateToTargets() saves the syndication URL it commits to Gitea → triggers another build → second CI call also hits the syndicate endpoint → duplicate Create(Note) activity sent. Root cause: the AP syndicator UID (publicationUrl) shares the same origin as the syndication URL (properties.url), so force mode re-selects it. Fix: At the start of syndicate(), query ap_activities for an existing outbound Create/Announce/Update for properties.url. If found, return the existing URL without re-federating.

patch-ap-mastodon-delete-fix (2026-04-01)

File: lib/mastodon/routes/statuses.js (delete route) + index.js Bug 1 (ReferenceError): Delete route used objectId (undefined) instead of item._id from findTimelineItemById → every delete threw ReferenceError → 500 → timeline entry never removed. Bug 2 (no AP broadcast): Route called postContent.delete() directly, bypassing the Indiekit syndicator framework → no Delete(Note) activity sent to followers → post persists on Mastodon. Fix: (a) Add broadcastDelete: (url) => pluginRef.broadcastDelete(url) to mastodonPluginOptions in index.js. (b) Call req.app.locals.mastodonPluginOptions.broadcastDelete(postUrl) after removing the timeline entry.

patch-micropub-delete-propagation + patch-bluesky-syndicator-delete (2026-04-01)

Files: node_modules/@indiekit/endpoint-micropub/lib/action.js + Bluesky syndicator Problem: Micropub action=delete only deleted the post from the content store. AP and Bluesky syndications persisted. Fix: After postContent.delete(), iterate publication.syndicationTargets and call syndicator.delete(url, syndication) fire-and-forget for any syndicator exposing .delete(). Bluesky syndicator extended with deletePost(bskyUrl) (via com.atproto.repo.deleteRecord) and delete(url, syndication) that resolves the bsky.app URL from the preserved _deletedProperties.

patch-ap-inbox-publication-url (via 63bc41ebb, 2026-04-01)

File: lib/controllers/federation-setup.js Problem: collections._publicationUrl was never set in federation-setup.js, so every pubUrl && objectId.startsWith(pubUrl) guard in handleCreate/handleAnnounce always evaluated to undefined → no reply notifications, no boost notifications for own content, replies from non-followers not stored in ap_timeline. Fix: Set collections._publicationUrl = publicationUrl before registerInboxListeners(). Also added else-if branch in handleCreate to store replies to own posts in ap_timeline even when sender is not in ap_following.

patch-ap-status-reply-id (2026-04-01)

Files: lib/mastodon/entities/status.js + lib/mastodon/routes/statuses.js Problem: in_reply_to_id in the status serializer was a tautological item.inReplyTo ? null : null (unfilled TODO) — always null. Mastodon clients (Phanpy/Elk) use this field to display reply threading; without it, own replies appear as standalone posts. Fix (two parts): (A) status.js: return item.inReplyToId || null instead of the tautological null. (B) statuses.js POST handler: when pre-inserting own posts into ap_timeline (reply-threading patch), also store inReplyToId: inReplyToId || null — the raw in_reply_to_id cursor from the client is already a valid encodeCursor value. Note: Inbound AP replies from remote servers still have inReplyToId = null (separate patch needed). Own replies via the Mastodon client API are fully fixed. Effect: Own replies are threaded correctly in Phanpy/Elk.


detectProtocol() in Microsub Reader

detectProtocol(url) in reader.js classifies URLs for syndication auto-selection:

  • "atmosphere" — bsky.app / bluesky
  • "fediverse" — mastodon., mstdn., fosstodon., troet., social., misskey., pixelfed., hachyderm., infosec.exchange, chaos.social (extended by patch-microsub-reader-ap-dispatch)
  • "web" — everything else, including own blog URLs

Own blog URLs return "web", so auto-selection doesn't trigger for reply-to-own-reply in the microsub reader. This is harmless because checked: true in the config already pre-checks the AP target in the microsub reader's target.checked field.