# indiekit-server Personal [Indiekit](https://getindiekit.com/) deployment for [blog.giersig.eu](https://blog.giersig.eu). Built on top of the [rmdes/indiekit](https://github.com/rmdes/indiekit) fork ecosystem. Several packages are sourced from custom forks (see below) and a set of patch scripts handle fixes that cannot yet be upstreamed. --- ## Fork-based dependencies Four packages are installed directly from Gitea forks rather than the npm registry: | Dependency | Source | Reason | |---|---|---| | `@rmdes/indiekit-endpoint-activitypub` | [svemagie/indiekit-endpoint-activitypub](https://gitea.giersig.eu/svemagie/indiekit-endpoint-activitypub) | DM support, likes-as-bookmarks, OG images in AP objects, draft/unlisted outbox guards, merged with upstream post-3.8.1 | | `@rmdes/indiekit-endpoint-blogroll` | [svemagie/indiekit-endpoint-blogroll#bookmark-import](https://gitea.giersig.eu/svemagie/indiekit-endpoint-blogroll/src/branch/bookmark-import) | Bookmark import feature | | `@rmdes/indiekit-endpoint-microsub` | [svemagie/indiekit-endpoint-microsub#bookmarks-import](https://gitea.giersig.eu/svemagie/indiekit-endpoint-microsub/src/branch/bookmarks-import) | Bookmarks import feature | | `@rmdes/indiekit-endpoint-youtube` | [svemagie/indiekit-endpoint-youtube](https://gitea.giersig.eu/svemagie/indiekit-endpoint-youtube) | OAuth 2.0 liked-videos sync as "like" posts | In `package.json` these use the `git+https://gitea.giersig.eu/svemagie/repo` syntax so npm fetches them directly from Gitea on install. > **Lockfile caveat:** The fork dependency is resolved to a specific commit in `package-lock.json`. When fixes are pushed to the fork, run `npm install git+https://gitea.giersig.eu/svemagie/indiekit-endpoint-activitypub` to pull the latest commit. The fork HEAD is at `b54146c` (upstream v3.9.x merged: Fedify 2.1.0, 5 FEPs — Tombstone/soft-delete, Activity Intents, indexable actor, NodeInfo enrichment, Collection Sync; security audit — XSS/CSRF/OAuth scope enforcement, rate limiting, token expiry, secret hashing; architecture refactor — syndicator.js, batch-broadcast.js, init-indexes.js, CSS split into 15 files; plus all fork patches: DM support, pin/unpin status, edit post, favourite/reblog timeout guard, raw signed fetch fallback, timezone-aware status lookup, own Micropub posts mirrored into ap_timeline, inbox HTTP Signature noise suppressed, OAuth `state` parameter echo fix). --- ## ActivityPub federation The blog is a native ActivityPub actor (`@svemagie@blog.giersig.eu`) powered by [Fedify](https://fedify.dev/) v2.1.0 via the `@rmdes/indiekit-endpoint-activitypub` package. All federation routes are mounted at `/activitypub`. ### Actor identity | Field | Value | |---|---| | Handle | `svemagie` (`AP_HANDLE` env var) | | Actor URL | `https://blog.giersig.eu/activitypub/users/svemagie` | | Actor type | `Person` | | WebFinger | `acct:svemagie@blog.giersig.eu` | | Migration alias | `https://troet.cafe/users/svemagie` (`AP_ALSO_KNOWN_AS`) | ### Key management Two key pairs are persisted in MongoDB (`ap_keys` collection) and loaded by the key pairs dispatcher: | Algorithm | Purpose | Storage format | Generation | |---|---|---|---| | RSA 2048-bit | HTTP Signatures (Mastodon/Pleroma standard) | PEM (`publicKeyPem` + `privateKeyPem`) | `preflight-activitypub-rsa-key.mjs` at startup | | Ed25519 | Object Integrity Proofs (newer standard) | JWK (`publicKeyJwk` + `privateKeyJwk`) | Auto-generated on first use | The RSA key is mandatory. The preflight script generates it if missing and repairs broken documents. Ed25519 is optional and fails gracefully. ### Message queue and delivery ``` Post created via Micropub ↓ syndicator.syndicate(properties) ↓ jf2ToAS2Activity() → Create/Like/Announce ↓ ctx.sendActivity({ identifier }, "followers", activity, { preferSharedInbox: true, // batch by shared inbox syncCollection: true, // FEP-8fcf collection sync orderingKey: postUrl, // deduplication }) ↓ Redis message queue (5 parallel workers) ↓ Fedify signs with RSA key → HTTP POST to follower inboxes ``` **Queue backends:** | Backend | When used | Notes | |---|---|---| | `RedisMessageQueue` + `ParallelMessageQueue` (5 workers) | `REDIS_URL` is set | Production: persistent, survives restarts | | `InProcessMessageQueue` | No Redis | **Not production-safe**: queue lost on restart | **KV store:** Redis (`RedisKvStore`) when available, otherwise MongoDB (`MongoKvStore`). Stores idempotence records, public key cache, remote document cache. ### Federation options ```javascript createFederation({ kv, queue, signatureTimeWindow: { hours: 12 }, // accept Mastodon retry signatures allowPrivateAddress: true, // own-site resolves to 10.100.0.10 }); ``` - **`signatureTimeWindow: { hours: 12 }`** — Mastodon retries failed deliveries with the original signature, which can be hours old. Without this, retries are rejected. - **`allowPrivateAddress: true`** — blog.giersig.eu resolves to a private IP (10.100.0.10) on the home LAN. Without this, Fedify's SSRF guard blocks WebFinger and `lookupObject()` for own-site URLs, breaking federation. ### Inbox handling Incoming activities go through `createFedifyMiddleware` → `federation.fetch()`. Registered inbox listeners: | Activity type | Handler | |---|---| | Follow | Accept/store in `ap_followers` | | Undo | Remove follow/like/announce | | Like | Store in `ap_activities` | | Announce | Store in `ap_activities` | | Create | Store in `ap_activities` (notes, replies) | | Delete | Remove referenced activity | | Update | Update referenced activity | | Flag | Log report | | Move | Update follower actor URL | | Block | Remove follower | | View | No-op (PeerTube watch events, silently ignored) | ### Outbox and collections | Collection | MongoDB collection | Endpoint | |---|---|---| | Outbox | `ap_activities` | `/activitypub/users/svemagie/outbox` | | Followers | `ap_followers` | `/activitypub/users/svemagie/followers` | | Following | `ap_following` | `/activitypub/users/svemagie/following` | | Liked | `ap_interactions` | `/activitypub/users/svemagie/liked` | | Featured | `ap_featured` | `/activitypub/users/svemagie/featured` | ### JF2 to ActivityStreams conversion Posts are converted from Indiekit's JF2 format to ActivityStreams 2.0 in two modes: 1. **`jf2ToAS2Activity()`** — Fedify vocab objects for outbox delivery (Create wrapping Note/Article) 2. **`jf2ToActivityStreams()`** — Plain JSON-LD for content negotiation on post URLs | Post type | Activity | Object | Notes | |---|---|---|---| | note | Create | Note | Plain text/HTML content | | article | Create | Article | Has `name` (title) and optional `summary` | | like | Create | Note | Delivered as bookmark (🔖 emoji + URL, `#bookmark` tag); same as bookmark handling | | repost | Announce | URL | `to: Public` (upstream @rmdes addressing); content negotiation serves as Note | | bookmark | Create | Note | Content prefixed with bookmark emoji + URL | | reply | Create | Note | `inReplyTo` set, author CC'd and Mentioned | **Visibility mapping:** | Visibility | `to` | `cc` | |---|---|---| | public (default) | `as:Public` | followers | | unlisted | followers | `as:Public` | | followers | followers | _(none)_ | **Content processing:** - Bare URLs auto-linked via `linkifyUrls()` - Permalink appended to content body - Nested hashtags normalized: `on/art/music` → `#music` (Mastodon doesn't support path-style tags) - Sensitive posts flagged with `sensitive: true`; summary doubles as CW text for notes - Per-post OG image added to Note/Article objects (`/og/{slug}.png`) for fediverse preview cards ### Express ↔ Fedify bridge `federation-bridge.js` converts Express requests to standard `Request` objects for Fedify: - **Body buffering**: For `application/activity+json` POSTs, the raw stream is buffered into `req._rawBody` (original bytes) and `req.body` (parsed JSON). This is critical because `JSON.stringify(req.body)` produces different bytes than the original, breaking the `Digest` header that Fedify uses for HTTP Signature verification. - **PeerTube View short-circuit**: If the buffered body has `type === "View"`, returns 200 immediately before Fedify's JSON-LD parser sees it (PeerTube's Schema.org extensions crash the parser). - **Mastodon attachment fix**: `sendFedifyResponse()` ensures `attachment` is always an array (JSON-LD compaction collapses single-element arrays, breaking Mastodon's profile field display). ### AP-specific patches These patches are applied to `node_modules` via postinstall and at serve startup. They're needed because some fixes cannot be upstreamed or because they adapt upstream behaviour to this blog's specific URL structure. | Patch | Target | What it does | |---|---|---| | `patch-ap-allow-private-address` | federation-setup.js | Adds `signatureTimeWindow` and `allowPrivateAddress` to `createFederation()` | | `patch-ap-url-lookup-api` | Adds new route | Public `GET /activitypub/api/ap-url` resolves blog URL → AP object URL | | `patch-ap-og-image` | jf2-to-as2.js | Fixes OG image URL generation — see below | | `patch-federation-unlisted-guards` | endpoint-syndicate | Prevents unlisted posts from being re-syndicated (AP fork has this natively) | | `patch-endpoint-activitypub-locales` | locales | Injects German (`de`) translations for the AP endpoint UI | **`patch-ap-og-image.mjs`** The fork (both 842fc5af and 45f8ba9) attempts to derive the OG image path by matching a date-based URL pattern like `/articles/2024/01/15/slug/`. This blog uses flat URLs (`/articles/slug/`) with no date component, so the regex never matches and no `image` property is set on ActivityPub objects — Mastodon and other clients never show a preview card. The patch replaces the broken date-from-URL regex with a simple last-path-segment extraction, producing `/og/{slug}.png` — the actual filename the Eleventy build generates (e.g. `/og/2615b.png`). Applied to both `jf2ToActivityStreams()` (plain JSON-LD) and `jf2ToAS2Activity()` (Fedify vocab objects). ### AP environment variables | Variable | Default | Purpose | |---|---|---| | `AP_HANDLE` | `"svemagie"` | Actor handle (username part of `@handle@domain`) | | `AP_ALSO_KNOWN_AS` | — | Mastodon profile URL for account migration (`alsoKnownAs`) | | `AP_LOG_LEVEL` | `"info"` | Fedify log level: `debug` / `info` / `warning` / `error` / `fatal` | | `AP_DEBUG` | — | Set to `1` or `true` to enable Fedify debug dashboard at `/activitypub/__debug__/` | | `AP_DEBUG_PASSWORD` | — | Password-protect the debug dashboard | | `REDIS_URL` | — | Redis connection string for message queue + KV store | ### Troubleshooting **All inbound AP activities return 401 / remote servers stop delivering** The root cause is usually the `host` header being forwarded as the nginx upstream IP instead of the canonical `blog.giersig.eu`. Fedify includes `host` in the signed-string for Cavage HTTP Signatures; if it doesn't match what the remote server signed, every inbox POST fails verification. Fixed by `patch-ap-signature-host-header`: overrides `"host"` with `new URL(publicationUrl).host` in `fromExpressRequest()` after copying headers from the Express request. **`ERR fedify·federation·inbox Failed to verify the request's HTTP Signatures`** At low volume this is expected (deleted actors, migrated servers with stale keys). `patch-ap-inbox-delivery-debug` changes the log level for `["fedify","federation","inbox"]` from `"fatal"` to `"error"` so real delivery failures are visible. If you see it flooding, the most common cause is the `host` header mismatch above — check that `patch-ap-signature-host-header` is applied. The body buffering patch must also preserve raw bytes in `req._rawBody` — if `JSON.stringify(req.body)` is used instead, the Digest header won't match. **Mastodon client OAuth fails with "OAuth callback failed. Missing parameters."** The OAuth 2.0 spec requires the server to echo the `state` parameter back in the authorization redirect. Mastodon clients (e.g. murmel.social) send a random `state` value for CSRF protection and fail if it is absent from the callback. Fixed in `b54146c`: `state` is now threaded through GET query → session store (surviving the IndieAuth login redirect) → hidden form field → POST body → callback URL (both approve and deny paths). **Activities appear in outbox but Mastodon doesn't receive them** 1. Check Redis connectivity: `redis-cli -h 10.100.0.20 ping` 2. Look for `[ActivityPub] Using Redis message queue` in startup logs 3. Set `AP_LOG_LEVEL=debug` to see Fedify delivery attempts 4. Verify `allowPrivateAddress: true` is in `createFederation()` — without it, Fedify blocks own-site URL resolution **Patch chain dependency**: `patch-ap-allow-private-address` adds both `signatureTimeWindow` and `allowPrivateAddress`. It handles both fresh v2.10.1 (no prior patches) and already-patched files. If it logs "snippet not found — skipping", the base code structure has changed and the patch needs updating. --- ## Outgoing webmentions The blog sends [webmentions](https://www.w3.org/TR/webmention/) to every external URL found in a published post. This is handled by the `@rmdes/indiekit-endpoint-webmention-sender` plugin, extended by several patches and a shell-based poller. ### How it works ``` Post created via Micropub → saved to MongoDB ↓ Shell poller (every 300s) POSTs to /webmention-sender?token=JWT ↓ Plugin queries MongoDB for posts with webmention-sent != true ↓ For each unsent post: 1. Fetch the live HTML page (not stored content) 2. Parse with microformats — scope to .h-entry 3. Extract all links 4. Filter to external links only 5. For each link: discover webmention endpoint via / HTTP header 6. Send webmention (source=postUrl, target=linkUrl) 7. Mark post as webmention-sent with results {sent, failed, skipped} ``` ### Why live-fetch instead of stored content Post content stored in MongoDB (`post.properties.content.html`) is just the post body text. It does **not** contain the microformat links rendered by the Eleventy templates: - `u-in-reply-to` — rendered by `reply-context.njk` inside the `.h-entry` wrapper - `u-like-of` — same template - `u-repost-of` — same template - `u-bookmark-of` — same template These links are **not** stored in MongoDB — only the live rendered page contains them. The livefetch patch (`patch-webmention-sender-livefetch.mjs`) solves this by building synthetic h-entry HTML from stored post properties directly, without fetching the live page. ### How the livefetch patch works (v6) Instead of fetching the live page, v6 reads the stored post properties from MongoDB and builds a minimal synthetic HTML document: ```html
…stored content…
``` This avoids all the networking complexity (nginx routing, Host headers, TLS, 502s) and is reliable even during deploys. The `extractLinks` function scopes to `.h-entry` and finds the anchor tags regardless of text content. ### Poller architecture (start.sh) The webmention sender plugin does not have its own scheduling — it exposes an HTTP endpoint that triggers a scan when POSTed to. The `start.sh` script runs a background shell loop: 1. **Readiness check** — polls `GET /webmention-sender/api/status` directly on `INDIEKIT_BIND_HOST:PORT` every 2s until it returns 200 (up to 3 minutes). This ensures MongoDB collections and plugin routes are fully initialised before the first scan. 2. **JWT generation** — mints a short-lived token (`{ me, scope: "update" }`, 5-minute expiry) signed with `SECRET`. 3. **POST trigger** — `curl -X POST /webmention-sender?token=JWT` triggers one scan cycle. 4. **Sleep** — waits `WEBMENTION_SENDER_POLL_INTERVAL` seconds (default 300 = 5 minutes), then repeats. The poller connects **directly to Indiekit** (`http://INDIEKIT_BIND_HOST:PORT`) — not through nginx. This is essential because nginx's `000-defaults.conf` returns HTTP 444 (connection drop, no response) for any request whose `Host` header doesn't match a known `server_name`. The poller's curl sends `Host: 10.100.0.20` (the jail IP), which matches no `server_name`, so routing through nginx would silently fail. ### Retry behaviour If a post's stored properties can't produce any external links (e.g. `in-reply-to` is missing), the post is still marked as sent with empty results. This is correct behaviour — if the properties are genuinely empty there's nothing to retry. If the properties were incorrectly stored, bump the `MIGRATION_ID` in `patch-webmention-sender-reset-stale.mjs` to force a re-scan after fixing the data. ### Patches | Patch | Purpose | |---|---| | `patch-webmention-sender-livefetch.mjs` | **(v6)** Builds synthetic h-entry HTML from stored post properties (no live fetch). Logs which property links were found per post. Upgrades from any prior version (v1–v5) in-place. | | `patch-webmention-sender-retry.mjs` | Superseded by livefetch. Silently skips when any livefetch version marker is present (regex matches `[patched:livefetch]` and `[patched:livefetch:vN]`). Kept as safety fallback. | | `patch-webmention-sender-reset-stale.mjs` | One-time MongoDB migration (v11): resets posts incorrectly marked as sent with empty results. Matches both old numeric-zero format and new v1.0.6+ empty-array format. Guarded by `migrations` collection (`webmention-sender-reset-stale-v11`). | | `patch-webmention-sender-empty-details.mjs` | UI patch: shows "No external links discovered" in the dashboard when a post was processed but had no outbound links (instead of a blank row). | ### Patch ordering Patches run alphabetically via `for patch in scripts/patch-*.mjs`. For webmention patches: 1. `patch-webmention-sender-empty-details.mjs` — targets the `.njk` template (independent) 2. `patch-webmention-sender-livefetch.mjs` — replaces the fetch block in `webmention-sender.js` 3. `patch-webmention-sender-reset-stale.mjs` — MongoDB migration (independent) 4. `patch-webmention-sender-retry.mjs` — detects any livefetch version marker via regex and silently skips; logs "already applied" ### Environment variables | Variable | Default | Purpose | |---|---|---| | `WEBMENTION_SENDER_POLL_INTERVAL` | `300` | Seconds between poll cycles | | `WEBMENTION_SENDER_MOUNT_PATH` | `/webmention-sender` | Plugin mount path in Express | | `WEBMENTION_SENDER_TIMEOUT` | `10000` | Per-endpoint send timeout (ms) | | `WEBMENTION_SENDER_USER_AGENT` | `"Indiekit Webmention Sender"` | User-Agent for outgoing requests | | `INTERNAL_FETCH_URL` | — | Direct Indiekit URL for self-fetches (e.g. `http://10.100.0.20:3000`) | | `SECRET` | _(required)_ | JWT signing secret for poller authentication | ### Troubleshooting **"No external links discovered in this post"** The livefetch patch built the synthetic h-entry but no external links were found. Check the startup log for the line `[webmention] Built synthetic h-entry for : N prop link(s) [in-reply-to]`. If it says `0 prop link(s) [none]`, the relevant property (`in-reply-to`, `like-of`, etc.) is missing from stored post properties in MongoDB — the data was never saved correctly. If the post was previously processed with 0 results due to the old live-fetch bugs, bump `MIGRATION_ID` in `patch-webmention-sender-reset-stale.mjs` and restart. **"webmention-sender not ready after 180s" / "Empty reply from server"** The readiness check or poll is routing through nginx, which returns 444 (connection drop) for requests with an unrecognised `Host` header. The poller must connect directly to `INDIEKIT_BIND_HOST:PORT`, not through `INTERNAL_FETCH_URL`. Check that `start.sh` uses `INDIEKIT_DIRECT_URL` (not `INTERNAL_FETCH_URL`) for `WEBMENTION_ENDPOINT`. **Posts stuck as "not sent" / not appearing in the dashboard** The post was processed with empty results before the livefetch v6 fix was deployed. Bump `MIGRATION_ID` in `patch-webmention-sender-reset-stale.mjs` to force a re-scan on next restart. **Previously failed posts not retrying** Bump the `MIGRATION_ID` in `scripts/patch-webmention-sender-reset-stale.mjs` to a new version string and restart. The migration resets all posts marked as sent with empty results (both numeric-zero and empty-array formats). It is idempotent per ID — bumping the ID forces it to run once more. --- ## YouTube likes sync The blog syncs YouTube liked videos as IndieWeb "like" posts. Powered by the forked `@rmdes/indiekit-endpoint-youtube` with an added OAuth 2.0 flow. ### How it works ``` First sync after connecting: YouTube API → fetch all liked video IDs → store in youtubeLikesSeen collection (no posts created — baseline snapshot only) Every subsequent sync (hourly background + manual trigger): YouTube API → fetch liked videos → compare against youtubeLikesSeen ↓ new like found (not in seen set) Mark as seen → generate markdown via publication.postTemplate() → write file to GitHub store via store.createFile() → insert post document into MongoDB posts collection ↓ already seen Skip ``` Only likes added **after** the initial connection produce posts. Existing likes (e.g. 200 historical ones) are baselined without generating posts. Like posts are created as **drafts** (`post-status: draft` → `draft: true` in Eleventy frontmatter) with content `Video Title - Channel Name`. The markdown file is committed to the GitHub `blog` repo via `@indiekit/store-github`, following the same flow as Micropub-created posts (postTemplate → store.createFile). Reset also deletes files from the store. ### OAuth 2.0 setup The YouTube Data API requires OAuth 2.0 (not just an API key) to access a user's liked videos. 1. Create an **OAuth 2.0 Client ID** (Web application) in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 2. Add authorized redirect URI: `https://blog.giersig.eu/youtube/likes/callback` 3. Ensure **YouTube Data API v3** is enabled for the project 4. Set environment variables: | Variable | Description | |---|---| | `YOUTUBE_OAUTH_CLIENT_ID` | OAuth 2.0 client ID | | `YOUTUBE_OAUTH_CLIENT_SECRET` | OAuth 2.0 client secret | > **Brand Account caveat:** If your YouTube channel runs under a Brand Account, you must authorize the Brand Account (not your personal Google account) during the OAuth consent screen. The `myRating=like` API call only returns likes for the authenticated account. If you see "account is closed", you selected the wrong account. ### Routes | Route | Auth | Description | |---|---|---| | `GET /youtube/likes` | Yes | Dashboard: OAuth status, sync info, controls | | `GET /youtube/likes/connect` | Yes | Starts OAuth flow (redirects to Google) | | `GET /youtube/likes/callback` | No | OAuth callback (Google redirects here) | | `POST /youtube/likes/disconnect` | Yes | Removes stored tokens | | `POST /youtube/likes/sync` | Yes | Triggers manual sync | | `POST /youtube/likes/reset` | Yes | Deletes all like posts (GitHub + MongoDB), seen IDs, baseline | | `GET /youtube/api/likes` | No | Public JSON API (`?limit=N&offset=N`) | ### MongoDB collections | Collection | Purpose | |---|---| | `youtubeMeta` | OAuth tokens (`key: "oauth_tokens"`), sync status (`key: "likes_sync"`), baseline flag (`key: "likes_baseline"`) | | `youtubeLikesSeen` | Set of all video IDs seen so far (indexed on `videoId`, unique). Prevents duplicate post creation and ensures only new likes after baseline produce posts. | ### Configuration ```javascript "@rmdes/indiekit-endpoint-youtube": { oauth: { clientId: process.env.YOUTUBE_OAUTH_CLIENT_ID, clientSecret: process.env.YOUTUBE_OAUTH_CLIENT_SECRET, }, likes: { syncInterval: 3_600_000, // 1 hour (default) maxPages: 3, // 50 likes/page → up to 150 per sync autoSync: true, // background periodic sync }, }, ``` ### Quota usage `videos.list?myRating=like` costs **1 quota unit per page** (50 videos). With defaults (3 pages/sync, hourly): ~72 units/day out of the 10,000 daily quota. --- ## Patch scripts Patches are Node.js `.mjs` scripts in `scripts/` that surgically modify files in `node_modules` after install. They are idempotent (check for a marker string before applying) and run automatically via `postinstall` and at the start of `serve`. ### ActivityPub > See also the [ActivityPub federation](#activitypub-federation) section above for a full architecture overview. **`patch-ap-allow-private-address.mjs`** Adds `signatureTimeWindow: { hours: 12 }` and `allowPrivateAddress: true` to `createFederation()`. Handles both fresh v2.10.1 and already-patched files. Without this, Fedify rejects Mastodon retry signatures and blocks own-site URL resolution on the private LAN. **`patch-ap-url-lookup-api.mjs`** Adds a public `GET /activitypub/api/ap-url?url=` endpoint that resolves a blog post URL to its canonical Fedify-served AP object URL. Used by the "Also on fediverse" widget for `authorize_interaction`. **`patch-endpoint-activitypub-locales.mjs`** Injects German (`de`) locale overrides into `@rmdes/indiekit-endpoint-activitypub` (e.g. "Benachrichtigungen", "Mein Profil"). The package ships only an English locale; this copies and customises it. **`patch-ap-federation-bridge-base-url.mjs`** Adds an optional `publicationUrl` parameter to `createFedifyMiddleware()` and `fromExpressRequest()`. When provided, uses the configured canonical URL as the base for all Fedify request objects instead of parsing `req.protocol` + `req.get("host")`. Required because nginx can omit `X-Forwarded-Proto` in some configurations, causing Fedify to see `http://` URLs that don't match its configured `https://` base — it then calls `next()`, the request falls through to auth middleware, and AP inbox/WebFinger clients receive a 302 redirect instead of AP JSON. **`patch-ap-compose-default-checked.mjs`** In the AP reader compose form (`/activitypub/admin/reader/compose`), the upstream code hardcoded `target.defaultChecked` to `true` only when `target.name === "@rick@rmendes.net"` — the upstream developer's own handle, which never matches on this installation. Fix: replaces the hardcoded comparison with `target.defaultChecked = target.checked === true`, so the pre-check state comes from the configured syndication target (which has `checked: true` in `indiekit.config.mjs`). Without this, the AP syndication checkbox is always unchecked in the AP reader compose form and replies written there are never federated. **`patch-ap-mastodon-reply-threading.mjs`** After `POST /api/v1/statuses` (Phanpy/Elk creates a post), the handler intentionally did not insert the new post into `ap_timeline` — it relied on the Eleventy build webhook firing 30–120 s later. If the user replies to their own new post during that window, `findTimelineItemById` returns null, `in_reply_to_id` is silently dropped, and the follow-up reply is classified as a "note" (wrong post type, wrong URL path, no `inReplyTo` in AP output, broken thread on Mastodon). Fix: immediately after `postContent.create()`, inserts a provisional timeline item via `addTimelineItem()` using `$setOnInsert` (idempotent — syndicator's later upsert is a no-op). **`patch-ap-mastodon-status-id.mjs`** `POST /api/v1/statuses` previously returned `id: String(Date.now())` — the wall-clock time of the HTTP response. But the `ap_timeline` item is stored with `published: data.properties.published`, which is set before the Gitea write (5–15 s earlier). When a client immediately replies to the freshly created post, it sends `in_reply_to_id` equal to `Date.now()`, which is 5–15 s later than the stored `published`. The ±1 s range query in `findTimelineItemById` misses, so `inReplyTo = null` and the reply is saved as a 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). The returned ID now matches what `findTimelineItemById` will resolve. **`patch-ap-status-reply-id.mjs`** Two-part fix for `in_reply_to_id` always being `null` in the Mastodon status serializer. (A) `status.js`: the field was a tautological `item.inReplyTo ? null : null` (unfilled TODO) — changed to `item.inReplyToId || null`. (B) `statuses.js` POST handler: when pre-inserting own posts into `ap_timeline` (reply-threading patch), also stores `inReplyToId: inReplyToId || null` — the raw `in_reply_to_id` cursor from the client is already a valid `encodeCursor` value. Inbound AP replies from remote servers will still have `inReplyToId = null` until a separate patch populates it from `ap_timeline` lookups. **`patch-ap-interactions-send-guard.mjs`** `likePost` and `boostPost` in `lib/mastodon/helpers/interactions.js` called `ctx.sendActivity()` without try/catch. Any Fedify or Redis error propagated to the caller → 500 response → the `ap_interactions` DB write never ran → interaction not recorded locally. Fix: wrap both `sendActivity` calls in try/catch so delivery failures are non-fatal. Interaction is still recorded in `ap_interactions`; client sees correct UI state. **`patch-ap-syndicate-dedup.mjs`** 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()`, queries `ap_activities` for an existing outbound Create/Announce/Update for `properties.url`. If found, returns the existing URL without re-federating. **`patch-ap-mastodon-delete-fix.mjs`** Two bugs in the Mastodon API delete route. Bug 1 (ReferenceError): the route used `objectId` (undefined) instead of `item._id` from `findTimelineItemById` → every delete threw ReferenceError → 500 → timeline entry never removed. Bug 2 (no AP broadcast): the route called `postContent.delete()` directly, bypassing the Indiekit syndicator framework → no `Delete(Note)` activity sent to followers → post persisted on Mastodon. Fix: (a) adds `broadcastDelete: (url) => pluginRef.broadcastDelete(url)` to `mastodonPluginOptions` in `index.js`; (b) calls `broadcastDelete(postUrl)` after removing the timeline entry. **`patch-ap-inbox-publication-url.mjs`** `collections._publicationUrl` was never set in `federation-setup.js`, so every `pubUrl && objectId.startsWith(pubUrl)` guard in `handleCreate`/`handleAnnounce` always evaluated to `undefined`. Result: no reply notifications, no boost notifications for own content, replies from non-followers not stored in `ap_timeline`. Fix: sets `collections._publicationUrl = publicationUrl` before `registerInboxListeners()`. Also adds an else-if branch in `handleCreate` to store replies to own posts in `ap_timeline` even when the sender is not in `ap_following`. **`patch-ap-inbox-delivery-debug.mjs`** The LogTape logger for `["fedify","federation","inbox"]` was hardcoded to `"fatal"` in `federation-setup.js`, suppressing all inbox errors including genuine delivery failures. Fix: changes the log level to `"error"` so real failures are visible. Expected noise from deleted/migrated actors (whose keys no longer resolve) still floods at `"error"` but can be filtered at the log aggregator level. **`patch-ap-signature-host-header.mjs`** `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()`, overrides `"host"` with `new URL(publicationUrl).host` (`"blog.giersig.eu"`) when `publicationUrl` is provided. ### Conversations **`patch-conversations-collection-guards.mjs`** Adds null-safety guards to `conversation-items.js` so the endpoint does not crash when the MongoDB `conversation_items` collection is missing or empty (returns an empty cursor instead of throwing). **`patch-conversations-mastodon-disconnect.mjs`** Patches the conversations endpoint to handle a missing or disconnected Mastodon account gracefully — prevents startup crashes when Mastodon credentials are not configured. **`patch-conversations-bluesky-cursor-fix.mjs`** The Bluesky `listNotifications` API paginates backwards in time — using the cursor as a polling marker causes the cursor to drift into the past until the poller fetches no new interactions at all. Fix: removes the `cursor` parameter from `fetchBlueskyNotifications` so every poll fetches the latest page, and clears any stale `bluesky_cursor` from the DB state. Deduplication relies on `platform_id` (unchanged). **`patch-conversations-bluesky-self-filter.mjs`** Self-interactions from the blog's own Bluesky account (likes, reposts, replies) appeared as inbound interactions in the conversations endpoint. Two-pronged fix: (1) the scheduler skips storing any notification whose author handle matches `BLUESKY_IDENTIFIER`; (2) the conversations controller filters out items where the author URL matches the site owner's Bluesky profile URL. ### Files **`patch-endpoint-files-upload-route.mjs`** Fixes the file upload XHR to POST to `window.location.pathname` instead of a hardcoded endpoint path, which broke uploads behind a custom mount prefix. Also adds fallback text for missing locale keys. **`patch-endpoint-files-upload-locales.mjs`** Injects German locale strings for the files endpoint. ### Media **`patch-endpoint-media-scope.mjs`** Changes the scope check from strict equality (`scope === "create"`) to `scope.includes("create")` so tokens with compound scopes (e.g. `"create update"`) can still upload media. **`patch-endpoint-media-sharp-runtime.mjs`** Wraps the `sharp` import with a lazy runtime loader so the server starts even if the native `sharp` binary is missing (falls back gracefully rather than crashing at import time). ### Frontend **`patch-frontend-sharp-runtime.mjs`** Same lazy `sharp` runtime guard applied to `@indiekit/frontend/lib/sharp.js` (avatar/image processing). Handles multiple nested copies of the package across the dependency tree. **`patch-frontend-serviceworker-file.mjs`** Ensures `@indiekit/frontend/lib/serviceworker.js` exists at the path the service worker registration expects, copying it from whichever nested copy of the package is present. **`patch-lightningcss.mjs`** Fixes the `~module/path` resolver in `lightningcss.js` to use `require.resolve()` correctly, preventing CSS build failures when module paths contain backslashes or when package hoisting differs. ### Micropub **`patch-micropub-session-token.mjs`** The Micropub action controller destructures the session token as `session.token`, but the IndieAuth middleware stores it as `session.access_token`. Result: `token` is `undefined` → `Authorization: Bearer undefined` → file upload via OwnYourSwarm always fails with 401. Fix: changes the destructuring to `const { scope, access_token: token }`. **`patch-endpoint-micropub-where-note-visibility.mjs`** Defaults OwnYourSwarm `/where` check-in notes to `visibility: unlisted` unless the post explicitly sets a visibility. Prevents accidental public syndication of location check-ins. **`patch-micropub-ai-block-resync.mjs`** Detects stale AI-disclosure block files and re-generates them on next post save. Fixes posts that had MongoDB AI fields set but missing or empty `_ai-block.md` sidecar files (caused by a previous bug where `supportsAiDisclosure` always returned false). **`patch-micropub-delete-propagation.mjs`** Micropub `action=delete` only deleted the post from the content store. AP and Bluesky syndications persisted. Fix: after `postContent.delete()`, iterates `publication.syndicationTargets` and calls `syndicator.delete(url, syndication)` fire-and-forget for any syndicator that exposes `.delete()`. AP syndicator broadcasts `Delete(Note)`; Bluesky syndicator deletes the bsky.app post (see `patch-bluesky-syndicator-delete`). ### Posts **`patch-endpoint-posts-ai-fields.mjs`** Adds AI disclosure field UI (text level, code level, etc.) to the post creation/editing form in `@rmdes/indiekit-endpoint-posts`. **`patch-endpoint-posts-ai-cleanup.mjs`** Removes AI disclosure fields from the post form submission before saving, delegating persistence to the AI block sidecar system. **`patch-endpoint-posts-fetch-diagnostic.mjs`** In the two-jail setup the node jail cannot reach `https://blog.giersig.eu` directly; self-referential fetches in `@indiekit/endpoint-posts` fail with `ECONNREFUSED`. Fix: rewrites self-referential fetch URLs to `http://localhost:` using `INTERNAL_FETCH_URL` (or an automatic fallback), and wraps the fetch in a try-catch that logs the URL and response status on failure to make networking problems easier to diagnose. **`patch-endpoint-posts-uid-lookup.mjs`** Fixes post editing 404s by adding `uid`-based lookup to the micropub source query. Without this, posts older than the first 40 results could not be opened for editing. **`patch-endpoint-posts-prefill-url.mjs`** Pre-fills the reference URL when creating posts from the `/news` "Post" button (`/posts/create?type=like&url=…`). The standard `postData.create` only reads `request.body`, ignoring query params. ### Preset / Eleventy **`patch-preset-eleventy-ai-frontmatter.mjs`** Adds AI disclosure fields (`aiTextLevel`, `aiCodeLevel`, etc.) to the Eleventy post template frontmatter so they are written into generated content files. ### Federation / Syndication **`patch-federation-unlisted-guards.mjs`** Prevents unlisted posts from being re-syndicated via `@indiekit/endpoint-syndicate`. The corresponding guards in `@rmdes/indiekit-endpoint-activitypub` are now built into the fork directly. ### Indiekit core **`patch-indiekit-endpoint-urls-protocol.mjs`** Endpoint URL resolution in `@indiekit/indiekit/lib/endpoints.js` uses `getUrl(request)` which returns the HTTP protocol (Express sees HTTP from nginx). This causes mixed-content failures in Safari when the frontend tries to load endpoint assets from `http://` URLs on an `https://` page. Fix: prefers `application.url` (the configured HTTPS base URL) over `getUrl(request)` when resolving relative endpoint paths. **`patch-indiekit-routes-rate-limits.mjs`** Replaces the single blanket rate limiter with separate strict (session/auth) and relaxed (general) limiters so legitimate API traffic is not throttled during normal use. **`patch-indiekit-error-production-stack.mjs`** Strips stack traces from error responses in `NODE_ENV=production` to avoid leaking internal file paths to clients. **`patch-indieauth-devmode-guard.mjs`** Gates dev-mode auto-login behind an explicit `INDIEKIT_ALLOW_DEV_AUTH=1` env var so `devMode: true` in config does not accidentally bypass authentication in staging/production. Also widens the redirect URL regex to allow encoded characters (`%`, `.`). ### Endpoints — misc **`patch-endpoint-homepage-locales.mjs`** Injects German locale strings for the homepage endpoint. **`patch-endpoint-homepage-identity-defaults.mjs`** Sets fallback values for identity fields on the dashboard when they are not configured, preventing blank/undefined display names. **`patch-endpoint-blogroll-feeds-alias.mjs`** Dual-mounts the blogroll public API at both `/blogrollapi` and `/rssapi`, and adds a `/api/feeds` alias for `/api/blogs`, so existing static pages that reference different base paths all resolve correctly. **`patch-endpoint-comments-locales.mjs`** Injects German locale strings for the comments endpoint. **`patch-endpoint-github-changelog-categories.mjs`** Extends the GitHub changelog controller with additional commit category labels. **`patch-endpoint-podroll-opml-upload.mjs`** Adds OPML file upload support to the podroll endpoint. ### Microsub / Reader **`patch-microsub-compose-draft-guard.mjs`** The Microsub reader compose form was syndicating draft posts. Fix: extracts `post-status` from the request body; when the post is a draft, clears all `mp-syndicate-to` targets before forwarding to Micropub, and forwards `post-status: draft` so the post is saved as a draft. **`patch-microsub-reader-ap-dispatch.mjs`** Three related issues in the Microsub reader's `detectProtocol()` and syndication dispatch: (1) the hardcoded fediverse domain list missed common instances (troet.cafe, hachyderm.io, etc.); (2) same-instance Mastodon URLs weren't detected because the naive hostname check didn't match against the configured target set; (3) likes and reposts of fediverse posts were not dispatched as native AP Like/Announce activities. Fix: extends the fediverse detection list; builds the Mastodon hostname set dynamically from configured syndication targets; dispatches native AP Like/Announce for likes/reposts after the Micropub POST succeeds. Serve-only (not postinstall). **`patch-microsub-feed-discovery.mjs`** Improves feed discovery in `fetchAndParseFeed`: when a bookmarked URL is an HTML page, falls back to `` discovery and a broader set of candidate paths rather than only the fixed short list. ### Listening (Funkwhale / Last.fm) **`patch-listening-endpoint-runtime-guards.mjs`** Applies several guards to the listening endpoints: scopes Funkwhale history fetches to the authenticated user (`scope: "me"`) rather than the entire instance, and adds null-safety for missing credentials so the server doesn't crash when these services aren't configured. ### Webmention sender **`patch-webmention-sender-hentry-syntax.mjs`** `@rmdes/indiekit-endpoint-webmention-sender` v1.0.8 shipped with a typo: `_html.includes("h-entry"")` — the extra closing quote is a `SyntaxError` that prevents the module from loading at all, so the webmention sender never starts. This patch runs first (alphabetically) and fixes the typo before any other webmention-sender patches apply. **`patch-webmention-sender-livefetch.mjs`** (v6) Replaces the upstream content-fetching block with a synthetic h-entry builder. Reads stored post properties directly from the MongoDB document (`in-reply-to`, `like-of`, `bookmark-of`, `repost-of`, `syndication`, `content.html`) and constructs a minimal `
` with the appropriate microformat anchor tags. No live page fetch, no nginx dependency, no networking failures. Logs which properties were found per post. Upgrades from any prior version (v1–v5) in-place. **`patch-webmention-sender-retry.mjs`** Predecessor to livefetch, now fully superseded. Silently skips when any livefetch version marker is detected (regex: `/\[patched:livefetch(?::v\d+)?\]/`). Kept as safety fallback in case livefetch fails to find its target. **`patch-webmention-sender-reset-stale.mjs`** (v11) One-time migration (guarded by a `migrations` MongoDB collection entry, currently `webmention-sender-reset-stale-v11`) that resets posts incorrectly marked as webmention-sent with empty results. Matches both old numeric-zero format and new v1.0.6+ empty-array format. Bump the `MIGRATION_ID` to re-run after future bugs. ### Bluesky syndicator **`patch-bluesky-syndicator-internal-url.mjs`** Rewrites own-domain fetch URLs in the Bluesky syndicator to `INTERNAL_FETCH_URL` for jailed setups. Covers `uploadMedia()` (photo uploads), `uploadImageFromUrl()` (OG image thumbnails), and `fetchOpenGraphData()` (OG metadata extraction). **`patch-bluesky-syndicator-delete.mjs`** Extends the Bluesky syndicator with a `delete(url, syndication)` method that deletes the corresponding bsky.app post via `com.atproto.repo.deleteRecord`. The bsky.app URL is resolved from `_deletedProperties` (the post properties preserved by Indiekit during `action=delete`). Triggered by `patch-micropub-delete-propagation`. ### Internal URL rewriting **`patch-micropub-fetch-internal-url.mjs`** Rewrites self-referential fetch URLs to `INTERNAL_FETCH_URL` (or `http://localhost:PORT`) across multiple endpoints: endpoint-syndicate, endpoint-share, microsub reader, activitypub compose, endpoint-posts, indieauth token exchange, token introspection, and media uploads. Required for jailed setups where the server cannot reach its own public HTTPS URL. **`patch-syndicate-force-checked-default.mjs`** When force-syndicating a post with no `mp-syndicate-to` and no existing syndication URLs, falls back to targets with `checked: true` instead of doing nothing. --- ## Preflight scripts Run at the start of `serve` before the server starts. They fail fast with a clear message rather than letting the server start in a broken state. | Script | Checks | |---|---| | `preflight-production-security.mjs` | `PASSWORD_SECRET` is set and bcrypt-hashed; blocks startup if missing in strict mode | | `preflight-mongo-connection.mjs` | MongoDB is reachable; blocks startup if connection fails in strict mode | | `preflight-activitypub-rsa-key.mjs` | RSA key pair for ActivityPub exists in MongoDB; generates one if absent | | `preflight-activitypub-profile-urls.mjs` | ActivityPub actor URLs are correctly configured; warns on mismatch | --- ## Server architecture The production setup uses two FreeBSD jails managed by [Bastille](https://bastillebsd.org/): ``` ┌─────────────────────────────────────────┐ Internet ──▶ 443 │ web jail (10.100.0.10) │ │ nginx — terminates TLS │ │ • static files (Eleventy _site output) │ │ • proxy_pass dynamic → node jail :3000 │ │ • port 80 for internal fetches (no TLS)│ └───────────────┬─────────────────────────┘ │ http://10.100.0.20:3000 ┌───────────────▼─────────────────────────┐ │ node jail (10.100.0.20) │ │ Indiekit (Express on port 3000) │ │ MongoDB (localhost or separate jail) │ └─────────────────────────────────────────┘ ``` ### Internal fetch URL The node jail cannot reach the public HTTPS URL (`https://blog.giersig.eu`) because TLS terminates on the web jail. Several features need to fetch their own pages or static assets: - **Webmention sender** — fetches live page HTML for link extraction - **Bluesky syndicator** — fetches photos for upload, OG metadata/images for link cards - **Micropub/syndicate** — self-fetches for token introspection, post updates All of these use a shared `_toInternalUrl()` helper (injected by patch scripts) that rewrites the public base URL to `INTERNAL_FETCH_URL`. This must point **directly to Indiekit** (node jail IP + port), not to nginx. ``` INTERNAL_FETCH_URL=http://10.100.0.20:3000 ``` **Why not nginx (`http://10.100.0.10`)?** nginx's HTTP/80 listener for `blog.giersig.eu` returns a `301` redirect to `https://`. Node's fetch follows the redirect to the public HTTPS URL, which the node jail cannot reach: pf's `rdr` rule only fires on the external interface (`vtnet0`), so there is no hairpin NAT for jail-originated traffic. The result is `UND_ERR_SOCKET: other side closed` on every internal POST (editing posts, syndication, token introspection). ### nginx configuration (`/usr/local/etc/nginx/sites/blog.giersig.eu.conf`) The full vhost config lives in the web jail. Key design points: - **ActivityPub content negotiation** — a `map` block (in `http {}`) detects AP clients by `Accept` header and routes them directly to Indiekit, bypassing `try_files`. - **Static-first serving** — browsers hit `try_files` in `location /`; static files are served from `/usr/local/www/blog` (Eleventy `_site/` output, rsynced on deploy). Unmatched paths fall through to `@indiekit`. - **Custom 404** — `error_page 404 /404.html` at the server level catches missing static files. `proxy_intercept_errors on` in `@indiekit` catches 404s from the Node upstream. Both serve Eleventy's generated `/404.html`. - **Internal listener** (`10.100.0.10:80`) — used by Indiekit for self-fetches only (not internet-facing). Must not intercept errors or redirect; must set `X-Forwarded-Proto: https` so Indiekit's force-https middleware doesn't redirect. ```nginx # ActivityPub content negotiation — place in http {} block map $http_accept $is_activitypub { default 0; "~*application/activity\+json" 1; "~*application/ld\+json" 1; } # ── 1. Internal HTTP listener (Indiekit self-fetches only) ────────────────── # Bound to jail IP, not exposed to the internet. # Passes responses through unmodified — no error interception. server { listen 10.100.0.10:80; server_name blog.giersig.eu; # Hardcode Host so Indiekit sees the real domain, not the jail IP. # X-Forwarded-Proto https prevents force-https from redirecting. proxy_set_header Host blog.giersig.eu; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; location /images/ { root /usr/local/www/blog; } location /og/ { root /usr/local/www/blog; } location / { proxy_pass http://10.100.0.20:3000; } } # ── 2. HTTP: giersig.eu + www → blog.giersig.eu ───────────────────────────── server { listen 80; server_name giersig.eu www.giersig.eu; return 301 https://blog.giersig.eu$request_uri; } # ── 3. HTTP: blog.giersig.eu (ACME challenge + HTTPS redirect) ────────────── server { listen 80; server_name blog.giersig.eu; location /.well-known/acme-challenge/ { root /usr/local/www/letsencrypt; } location / { return 301 https://blog.giersig.eu$request_uri; } } # ── 4. HTTPS: giersig.eu + www → blog.giersig.eu ──────────────────────────── server { listen 443 ssl; server_name giersig.eu www.giersig.eu; ssl_certificate /usr/local/etc/letsencrypt/live/giersig.eu/fullchain.pem; ssl_certificate_key /usr/local/etc/letsencrypt/live/giersig.eu/privkey.pem; include /usr/local/etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem; return 301 https://blog.giersig.eu$request_uri; } # ── 5. HTTPS: blog.giersig.eu (main) ──────────────────────────────────────── server { listen 443 ssl; http2 on; server_name blog.giersig.eu; ssl_certificate /usr/local/etc/letsencrypt/live/blog.giersig.eu/fullchain.pem; ssl_certificate_key /usr/local/etc/letsencrypt/live/blog.giersig.eu/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; add_header X-Bridgy-Opt-Out "yes" always; add_header Strict-Transport-Security "max-age=63072000" always; include /usr/local/etc/nginx/bots.d/ddos.conf; include /usr/local/etc/nginx/bots.d/blockbots.conf; root /usr/local/www/blog; index index.html; # Custom 404 — served from Eleventy build output. # proxy_intercept_errors in @indiekit ensures upstream 404s also use this. error_page 404 /404.html; location = /404.html { root /usr/local/www/blog; internal; } location = /contact { return 301 /hello; } location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # AP clients → proxy directly (bypasses try_files / static serving) if ($is_activitypub) { proxy_pass http://10.100.0.20:3000; } # Browsers → static file, then directory index, then .html extension, # then fall through to Indiekit for dynamic routes. try_files $uri $uri/ $uri.html @indiekit; } location @indiekit { proxy_pass http://10.100.0.20:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Intercept 404s from Node so error_page 404 above fires. proxy_intercept_errors on; } } ``` ### Key environment variables (node jail `.env`) | Variable | Example | Purpose | |---|---|---| | `INTERNAL_FETCH_URL` | `http://10.100.0.20:3000` | Direct Indiekit endpoint for self-fetches (must bypass nginx — see Internal fetch URL) | | `INDIEKIT_BIND_HOST` | `10.100.0.20` | Jail IP (loopback unavailable in jails); used by webmention poller | | `PORT` | `3000` | Indiekit listen port (default 3000) | --- ## Setup ```sh npm install # installs dependencies and runs all postinstall patches npm run serve # runs preflights + patches + starts the server ``` Environment variables are loaded from `.env` via `dotenv`. Copy `.env.example` to `.env` and fill in the required values. See `indiekit.config.mjs` for the full configuration. ### Required environment variables | Variable | Purpose | |---|---| | `SECRET` | Session signing secret — at least 32 characters | | `PASSWORD_SECRET` | bcrypt hash of the admin password (`$2a$…`) | | `MONGO_URL` or `MONGO_USERNAME`+`MONGO_PASSWORD` | MongoDB connection | | `GH_CONTENT_TOKEN` | GitHub token with write access to the `blog` repo | ### Key optional variables | Variable | Default | Purpose | |---|---|---| | `PUBLICATION_URL` | `https://blog.giersig.eu` | Canonical blog URL | | `INDIEKIT_URL` | same as `PUBLICATION_URL` | Application base URL | | `REDIS_URL` | — | Redis for AP message queue + KV store (production-required for persistence) | | `INTERNAL_FETCH_URL` | `http://localhost:PORT` | Direct Indiekit URL for self-fetches, bypassing nginx | | `INDIEKIT_BIND_HOST` | `0.0.0.0` | Jail IP to bind on (FreeBSD jails don't have loopback) | | `PORT` | `3000` | Listen port | | `AP_HANDLE` | `svemagie` | ActivityPub username | | `AP_ALSO_KNOWN_AS` | — | Old Mastodon account URL for migration alias | | `AP_LOG_LEVEL` | `info` | Fedify log verbosity | | `BLUESKY_HANDLE` | — | Bluesky handle for syndication | | `BLUESKY_PASSWORD` | — | Bluesky app password | | `WEBMENTION_IO_TOKEN` | — | Webmention.io API token | | `YOUTUBE_OAUTH_CLIENT_ID` | — | YouTube OAuth client ID for likes sync | | `YOUTUBE_OAUTH_CLIENT_SECRET` | — | YouTube OAuth client secret | | `LASTFM_API_KEY` | — | Last.fm API key | | `LASTFM_USERNAME` | — | Last.fm username | | `FUNKWHALE_INSTANCE` | — | Funkwhale instance URL | | `FUNKWHALE_USERNAME` | — | Funkwhale username | | `FUNKWHALE_TOKEN` | — | Funkwhale API token | --- ## Production deployment ### Startup script (`start.example.sh`) Copy `start.example.sh` to `start.sh` and make it executable. It: 1. Loads `.env` via `dotenv` 2. Validates required variables (`SECRET`, `PASSWORD_SECRET`, `MONGO_URL` or credentials, `GH_CONTENT_TOKEN`) and exits with a clear error if any are missing 3. Runs all preflight scripts (`scripts/preflight-*.mjs`) in order 4. Runs all patch scripts (`scripts/patch-*.mjs`) in order 5. Starts Indiekit via `node --require ./metrics-shim.cjs node_modules/.bin/indiekit serve` 6. Launches the webmention poller as a background subprocess (polls `WEBMENTION_SENDER_POLL_INTERVAL` seconds; default 300) 7. Traps `EXIT`/`INT`/`TERM` to cleanly shut down the poller when Indiekit exits The webmention poller connects **directly to Indiekit** at `INDIEKIT_BIND_HOST:PORT` (not through nginx). nginx returns HTTP 444 for requests with an unrecognised `Host` header; the poller's `Host: ` would match nothing and silently fail. ### FreeBSD rc.d service (`indiekit.rcd.example`) Copy `indiekit.rcd.example` to `/usr/local/etc/rc.d/indiekit` (in the node jail) and enable it with `sysrc indiekit_enable=YES`. The script: - Uses `daemon(8)` with `-P -p -o ` so both the supervisor and the Node process are tracked - Supports a configurable stop timeout (`indiekit_stop_timeout`, default 30 s) before sending SIGKILL - Supports `service indiekit reload` (sends SIGHUP) for configuration reloads without a full restart ### Prometheus metrics (`metrics-shim.cjs`) A CommonJS preload module that runs a Prometheus scrape endpoint on port 9209 (configurable via `METRICS_BIND_HOST` and `METRICS_PORT`). Loaded at startup via `node --require ./metrics-shim.cjs`. Exposes basic Node.js process metrics for scraping by a Prometheus instance. --- ## Changelog ### 2026-04-01 **chore(deps): upgrade @fedify/* 2.1.1 → 2.1.3** (`65c1813`) Fedify patch releases. No API changes; bug fixes in delivery and signature handling. **fix(ap): HTTP Signature verification fails for all inbound activities** (`55923be`) nginx forwards the upstream IP as the `Host` header. Fedify includes `host` in the Cavage signed-string; mismatched value → 401 on every inbox POST → remote servers exhaust retries and stop delivering. Fixed by `patch-ap-signature-host-header`: overrides `"host"` in `fromExpressRequest()` with `new URL(publicationUrl).host`. **fix(ap): add AP inbox diagnostics — surface signature errors and request logging** (`8b1b5d9`) `patch-ap-inbox-delivery-debug` changes the inbox LogTape level from `"fatal"` to `"error"`. Setting `AP_DEBUG=1` enables per-request body logging before Fedify's signature check. **feat(ap/bsky): propagate Micropub deletes to ActivityPub and Bluesky** (`e791c06`) `patch-micropub-delete-propagation`: after `postContent.delete()`, calls `syndicator.delete()` on all syndicators that expose `.delete()`. `patch-bluesky-syndicator-delete`: new Bluesky `.delete()` method via `com.atproto.repo.deleteRecord`. AP syndicator broadcasts `Delete(Note)` via `broadcastDelete()`. **fix(activitypub): inbound replies/notifications broken — publicationUrl missing in inbox handlers** (`63bc41e`) `patch-ap-inbox-publication-url`: sets `collections._publicationUrl` in `federation-setup.js` before inbox listener registration, enabling `handleCreate`/`handleAnnounce`/`handleLike` notifications for own content. Also stores replies to own posts from non-followers in `ap_timeline`. **fix(ap): status creation response id mismatch breaks immediate reply threading** (`patch-ap-mastodon-status-id`) `POST /api/v1/statuses` returned `id: String(Date.now())` (response time), but `ap_timeline` stores `published` (set pre-Gitea-write, 5–15 s earlier). Immediate follow-up replies sent `in_reply_to_id = Date.now()`, missing the ±1 s range query. Fixed: use `encodeCursor(data.properties.published)` as the response ID. **fix(ap): in_reply_to_id always null in Mastodon status serializer** (`patch-ap-status-reply-id`) Tautological `item.inReplyTo ? null : null` in `status.js` fixed to `item.inReplyToId || null`. POST handler now also stores `inReplyToId` in the timeline item so own replies are threaded in Phanpy/Elk. **fix(ap): favourite/reblog error crashes interaction recording** (`patch-ap-interactions-send-guard`) `sendActivity` in `likePost`/`boostPost` lacked try/catch; any Redis/Fedify error caused a 500 and skipped the `ap_interactions` DB write. Wrapped in try/catch so delivery failures are non-fatal. **fix(ap): duplicate Create/Announce activities on double CI webhook** (`patch-ap-syndicate-dedup`) CI webhook triggers twice per post (Gitea commit from syndication URL save triggers another build). `patch-ap-syndicate-dedup` checks `ap_activities` for an existing outbound activity for the URL and short-circuits if found. **fix(ap): Mastodon API delete: ReferenceError + no AP broadcast** (`patch-ap-mastodon-delete-fix`) Delete route used undefined `objectId` (should be `item._id`) → ReferenceError → 500. Also called `postContent.delete()` directly → no `Delete(Note)` sent to followers. Fixed: corrected variable + wired `broadcastDelete` through `mastodonPluginOptions`. ### 2026-03-27 **merge: upstream v3.9.x — Fedify 2.1.0, 5 FEPs, security/perf audit** (`230bfd1` in svemagie/indiekit-endpoint-activitypub) 14 upstream commits merged (`0820067..c1a6f7e`). Key changes: Fedify upgraded to 2.1.0; 5 FEP implementations added — FEP-4f05 soft-delete with Tombstone (deleted posts serve 410 + JSON-LD Tombstone, new `ap_tombstones` collection), FEP-3b86 Activity Intents (WebFinger links + `authorize_interaction` routes), FEP-5feb indexable/discoverable actor fields, FEP-f1d5/0151 enriched NodeInfo 2.1, FEP-8fcf Collection Sync outbound. Security audit fixes (27 issues): XSS/CSRF on OAuth authorization page, OAuth scope enforcement on all Mastodon API routes, rate limiting on API/auth/app-registration endpoints, access token expiry (1h) + refresh token rotation (90d), client secret hashing, SSRF fix, redirect_uri validation. Architecture refactoring: syndicator extracted to `lib/syndicator.js`, batch broadcast to `lib/batch-broadcast.js`, MongoDB index creation to `lib/init-indexes.js`, federation helpers to `lib/federation-actions.js` (`index.js` reduced by 35%); CSS split from one 3441-line `reader.css` into 15 feature-scoped files. Fork-specific conflict resolutions: `addTimelineItem` mirror moved from inline syndicator in `index.js` to `lib/syndicator.js`; fixed missing `await` on `jf2ToAS2Activity` in upstream's extracted syndicator; DM path, pin/unpin routes, edit post route, and `processStatusContent` retained in `statuses.js`; cache-first avatar approach retained in `enrich-accounts.js`; DM lock icon (🔒) retained in notification card template. **fix(accounts): missing tokenRequired/scopeRequired imports** (`b595734` in svemagie/indiekit-endpoint-activitypub) `accounts.js` started failing with `ReferenceError: tokenRequired is not defined` immediately on startup. During the merge conflict resolution, the upstream-added `tokenRequired`/`scopeRequired` imports in `accounts.js` were incorrectly dropped (they appeared to already exist in the file from a grep of the post-merge state, but in reality they were only referenced via route middleware, not imported). Fix: added the two missing `import` lines. **fix(index): missing resolveAuthor import** (`6f76ec4` in svemagie/indiekit-endpoint-activitypub) `resolveAuthor` from `lib/resolve-author.js` is used in `index.js` for like/boost delivery (within `batchBroadcast` handlers) but its import was dropped when the merge conflict replaced the inline syndicator block with `createSyndicator(this)`. Fix: restored the `import { resolveAuthor }` line. **fix(rate-limit): ERR_ERL_PERMISSIVE_TRUST_PROXY on every request** (`69ae731` in svemagie/indiekit-endpoint-activitypub) The new `express-rate-limit` middleware (from the upstream security audit) threw `ValidationError: ERR_ERL_PERMISSIVE_TRUST_PROXY` on every incoming request because the server sits behind nginx with `trust proxy: true` set in Express, which `express-rate-limit` v7+ treats as a misconfiguration warning by default. The error propagated up the middleware chain and caused Fedify to log spurious "Failed to verify HTTP Signatures" errors for all incoming inbox requests. Fix: added `validate: { trustProxy: false }` to all three rate limiter instances (`apiLimiter`, `authLimiter`, `appRegistrationLimiter`) in `lib/mastodon/router.js`, signalling that the trust proxy configuration is intentional. ### 2026-03-24 **fix(syndicate): own Micropub posts missing from ap_timeline** (`42f8c2d` in svemagie/indiekit-endpoint-activitypub) `GET /api/v1/statuses/:id/context` returned 404 for replies and notes authored via the website admin (Micropub pipeline). Root cause: `addTimelineItem` was only called from inbox handlers (incoming AP) and the Mastodon Client API `POST /api/v1/statuses` route (posts created through Phanpy/Elk). Posts created through Micropub (`syndicate()` in `index.js`) were sent as `Create(Note)` activities to followers but never inserted into `ap_timeline`, so the Mastodon Client API had no record to look up by ID or cursor. Fix: after `logActivity` in `syndicate()`, when the activity type is `Create`, insert the post into `ap_timeline` by mapping JF2 properties (content, summary, sensitive, visibility, inReplyTo, published, author, photo/video/audio, categories) to the timeline item shape. Uses `$setOnInsert` (atomic upsert) so re-syndication of the same URL is idempotent. **fix(linkify): trailing punctuation included in auto-linked URLs** (`bd3a623` in svemagie/indiekit-endpoint-activitypub) URLs at the end of a sentence (e.g. `"See https://example.com."`) had the trailing period captured as part of the URL, producing a broken link (`https://example.com.` → 404). Root cause: the regex `[^\s<"]+` in `linkifyUrls()` (`lib/jf2-to-as2.js`) and `/(https?:\/\/[^\s<>"')\]]+)/g` in `processStatusContent()` (`lib/mastodon/routes/statuses.js`) both match until whitespace or tag-open, but `.`, `,`, `;`, `:`, `!`, `?` are common sentence-ending characters that follow URLs. Fix: replace the string template in both replace calls with a callback that strips `/[.,;:!?)\]'"]+$/` from the captured URL before inserting into the `` tag. Applies to AP federation (outbox Notes) and Mastodon Client API post creation. ### 2026-03-23 **feat(mastodon-api): implement PUT /api/v1/statuses/:id (edit post)** (`e319c34` in svemagie/indiekit-endpoint-activitypub) `PUT /api/v1/statuses/:id` was not implemented, so "Beitrag bearbeiten" always failed. Route added to `lib/mastodon/routes/statuses.js`. Flow: (1) look up timeline item by cursor ID, 403 if not the local actor's own post; (2) build a Micropub `replace` operation for `content`, `summary`, `sensitive`, and `mp-language` and call `postData.update()` + `postContent.update()` to update the MongoDB posts collection and content file on disk; (3) patch the `ap_timeline` document in-place (`content`, `summary`, `sensitive`, `updatedAt`) — `serializeStatus` reads `updatedAt` → `edited_at`; (4) broadcast `Update(Note)` to all followers via shared inbox so remote servers display the edit pencil indicator; (5) return the serialized status. `Update` added to the top-level `@fedify/fedify/vocab` import. **feat(mastodon-api): implement pin/unpin status** (`b5ebf6a` in svemagie/indiekit-endpoint-activitypub) `POST /api/v1/statuses/:id/pin` and `POST /api/v1/statuses/:id/unpin` were returning 501 "Not implemented", so "In Profil anheften" always failed in Phanpy/Elk. Fix: both routes are now implemented in `lib/mastodon/routes/statuses.js`. Pin upserts a document into `ap_featured` (the same collection the admin UI uses), enforces the existing 5-post maximum, and calls `broadcastActorUpdate()` so remote servers re-fetch the AP featured collection immediately. Unpin deletes from `ap_featured` and broadcasts the same update. `loadItemInteractions()` now also queries `ap_featured` and returns a `pinnedIds` set, so `GET /api/v1/statuses/:id` correctly reflects pin state. `broadcastActorUpdate` wired into mastodon `pluginOptions` in `index.js`. **fix(mastodon-api): favourite still fails for timeline items stored with non-UTC timezone offsets** (`2660a1a` in svemagie/indiekit-endpoint-activitypub) `findTimelineItemById` converts the cursor ID (ms-since-epoch) to a UTC ISO string via `decodeCursor`, then tries exact string match against `published` in MongoDB. The UTC normalization fix in `a259c79` / `extractObjectData` ensures NEW inbox items are stored as UTC. But items already in the database from before that deploy still carry the original server's timezone offset (e.g., `"2026-03-21T16:33:50+01:00"`). The final fallback was a `$gte`/`$lte` range query on the string representation — which fails because `"16:33:50+01:00"` is lexicographically outside the UTC range `["15:33:50Z", "15:33:51Z"]`. Fix: replace the string range query with a `$or` that covers both storage formats: (1) BSON Date direct range comparison for Micropub-generated items, and (2) MongoDB `$dateFromString` + `$toLong` numeric range for string-stored dates. `$dateFromString` parses any ISO 8601 format including timezone offsets and returns a UTC Date; `$toLong` converts to ms-since-epoch; the numeric ±1 s window always matches regardless of how the original timezone was encoded. **merge: upstream raw signed fetch fallback for author resolution** (`c2920ca` merged into svemagie/indiekit-endpoint-activitypub as `b33932f`) Upstream added Strategy 1b to `resolveAuthor`: a raw signed HTTP fetch for servers (e.g. wafrn) that return ActivityPub JSON without `@context`, which Fedify's JSON-LD processor rejects and which `lookupWithSecurity` therefore cannot handle. The raw fetch extracts `attributedTo`/`actor` from the plain JSON, then resolves the actor URL via `lookupWithSecurity` as normal. Resolution: combined with our existing 5-second `Promise.race` timeout — `likePost`/`unlikePost`/`boostPost` now pass `privateKey`/`keyId` to `resolveAuthor` so the signed raw fetch can attach an HTTP Signature, while the timeout still guards all three resolution strategies against slow/unreachable remotes. **fix(mastodon-api): favourite/reblog blocks on unbound resolveAuthor requests → client timeout** (`01f6f81` in svemagie/indiekit-endpoint-activitypub) `likePost`, `unlikePost`, and `boostPost` in `lib/mastodon/helpers/interactions.js` all called `resolveAuthor()` — which makes up to 3 signed HTTP requests to the remote server (post fetch → actor fetch → `getAttributedTo()`) — with no timeout. If the remote server is slow or unreachable, the favourite/reblog HTTP response hangs until Node.js's socket default fires (~2 min). Mastodon clients (Phanpy, Elk) have their own shorter timeout and give up with "Failed to load post … Please try again later". Fix: wrap every `resolveAuthor()` call in `Promise.race()` with a 5 s cap. The interaction is still recorded in `ap_interactions` and the `Like`/`Announce` activity is still delivered when resolution succeeds within the window; on timeout, AP delivery is silently skipped but the client receives a correct 200 with the updated status (⭐ shows as toggled). **fix(mastodon-api): favourite/like returns "failed to load post" (404)** (`a259c79` in svemagie/indiekit-endpoint-activitypub) `POST /api/v1/statuses/:id/favourite` uses `findTimelineItemById` to resolve the status by its cursor ID (ms-since-epoch). Three failure modes were found: (1) Items written through the Micropub pipeline store `published` as a JavaScript `Date` → MongoDB BSON Date; a string comparison against `decodeCursor()`'s ISO output never matches. (2) Some AP servers emit `published` with a timezone offset (`+01:00`); `String(Temporal.Instant)` preserves the offset, so the stored string and the lookup key differ. (3) Items with an invalid or missing `published` date had their cursor set to `"0"` (truthy in JS) so `serializeStatus` used `"0"` as the ID instead of falling back to `item._id.toString()`, making them permanently un-lookupable. Fixes: `encodeCursor` now returns `""` (falsy) for invalid dates; `findTimelineItemById` adds a BSON Date fallback and a ±1 s ISO range query; `extractObjectData` in `timeline-store.js` now normalises `published` to UTC ISO before storing, so future items always match the exact-string lookup. **fix(mastodon): profile avatars disappear after first page load; actor created_at wrong timezone** (`da89554` in svemagie/indiekit-endpoint-activitypub) Two profile display regressions fixed: (1) `resolveRemoteAccount` fetched the correct avatar URL via `lookupWithSecurity` and applied it to the in-memory serialised status — but never stored it in the account cache. On the next request `serializeStatus` rebuilt the account from `item.author.photo` (empty for actors that were on a Secure Mode server when the timeline item was originally received), counts came from the in-memory cache so `enrichAccountStats`/`collectAccount` skipped re-fetching, and the avatar reverted to the default SVG. Fix: `cacheAccountStats` now stores `avatarUrl`; `collectAccount` always checks the cache first (before the "counts already populated" early-return) and applies `avatarUrl` + `createdAt`. (2) `actor.published` is a `Temporal.Instant`; `String()` on it preserves the original timezone offset (e.g. `+01:00`), so `created_at` in the Mastodon account entity could show a non-UTC timestamp that some clients refuse to parse. Fix: wrap in `new Date(String(...)).toISOString()` in both `resolve-account.js` and `timeline-store.js`. ### 2026-03-22 **fix(mastodon-api): follower/following accounts show wrong created_at; URL-type AP lookup** (`6c13eb8` in svemagie/indiekit-endpoint-activitypub) All places in `accounts.js` that build actor objects from `ap_followers`/`ap_following` documents were omitting the `createdAt` field. `serializeAccount()` fell back to `new Date().toISOString()`, so every follower and following account appeared to have joined "just now" in the Mastodon client. Fix: pass `createdAt: f.createdAt || undefined` in all five locations — the `/followers`, `/following`, `/lookup` endpoints and both branches of `resolveActorData()`. Additionally, HTTP actor URLs in `resolve-account.js` are now passed to `lookupWithSecurity()` as native `URL` objects instead of bare strings (matching Fedify's preferred type); the `acct:user@domain` WebFinger path stays as a string since WHATWG `new URL()` misparses the `@` as a user-info separator. **fix(mastodon): remote profile pictures and follower stats missing in Mastodon client** (`ed18446` in svemagie/indiekit-endpoint-activitypub) `resolveRemoteAccount()` in `lib/mastodon/helpers/resolve-account.js` called `ctx.lookupObject()` directly. Servers that return 400/403 for signed GETs (e.g. some Mastodon/Pleroma instances) caused the lookup to throw, so the function returned `null` — making profile pages show no avatar and zero follower/following/statuses counts. Fix: replace with `lookupWithSecurity()` (the same signed→unsigned fallback wrapper used everywhere else in the codebase) and obtain a `documentLoader` first so the signed attempt can attach the actor's HTTP signature. Additionally wrapped `getFollowers()`, `getFollowing()`, and `getOutbox()` collection fetches in a 5-second `Promise.race` timeout so slow remote servers no longer block the profile response indefinitely. **fix(mastodon-api): DM sent from Mastodon client created a public blog post** (`99964e9` in svemagie/indiekit-endpoint-activitypub) `POST /api/v1/statuses` with `visibility="direct"` fell through to the Micropub pipeline, which has no concept of Mastodon's `"direct"` visibility — so it created a normal public blog post. Fix: intercept `visibility === "direct"` before Micropub: resolve the `@user@domain` mention via WebFinger (Fedify lookup as fallback), build a `Create/Note` AP activity addressed only to the recipient (no public/followers `cc`), send via `ctx.sendActivity()`, store in `ap_notifications` for the DM thread view, return a minimal status JSON to the client. No blog post is created. **fix(mastodon-api): DM response returned "no data" in Mastodon client** (`4816033` in svemagie/indiekit-endpoint-activitypub) After the DM was sent, the Mastodon client received a bare `{}` object instead of a proper status entity, showing "no data". Root cause: the DM path returned a hand-rolled minimal JSON object instead of calling `serializeStatus()`. Fix: build a full `timelineItem` document (matching the shape used by the home timeline) and pass it through `serializeStatus()` so all ~20 required Mastodon status fields (`id`, `account`, `media_attachments`, `tags`, `emojis`, etc.) are present. **fix(mastodon-api): DM 404 immediately after send, then disappeared from thread view** (`7b838ea` in svemagie/indiekit-endpoint-activitypub) Follow-up to the "no data" fix: the DM item was never actually persisted because `addTimelineItem()` was called as `addTimelineItem(collections.ap_timeline, item)`, passing the raw MongoDB collection directly. `addTimelineItem` expects the whole `collections` object and destructures `{ ap_timeline }` from it — passing the collection itself caused `undefined.updateOne` to throw at insert time. The stored item was absent so the subsequent `GET /api/v1/statuses/:id` 404'd. Fix: pass `collections` (not `collections.ap_timeline`). **fix(activitypub): like/reblog from Mastodon client throws "collection.get is not a function"** (`0a686d7` in svemagie/indiekit-endpoint-activitypub) `resolveAuthor()` in `lib/resolve-author.js` called `collections.get("ap_timeline")` assuming a `Map` (correct for the native AP inbox path), but the Mastodon Client API passes `req.app.locals.mastodonCollections` as a plain object. Every favourite/reblog action from Phanpy, Elk, or any other Mastodon client hit this error. Fix: `typeof collections.get === "function"` guard selects between Map-style and object-style access so both paths work. **chore(patches): remove 11 obsolete AP patch scripts** (`18a946c9e`) All of the following features are now baked into `svemagie/indiekit-endpoint-activitypub` natively; the patch scripts were either no-ops or (in the case of `patch-ap-repost-commentary`) actively harmful (inserting a duplicate `else if` block on every deploy, preventing startup). Root cause: upstream merges absorbed our custom commits, leaving the OLD snippets absent from the source so patches silently skipped — except Fix D of repost-commentary which still matched a generic `} else {` block and corrupted `jf2-to-as2.js`. - `patch-ap-repost-commentary` — repost commentary in AP output (Create/Note with commentary) - `patch-ap-url-lookup-api` — `/api/ap-url` endpoint - `patch-ap-allow-private-address` — `allowPrivateAddress: true` in `createFederation` - `patch-ap-like-note-dispatcher` — reverted fake-Note approach for likes - `patch-ap-like-activity-id` — canonical `id` URI on Like activities (AP §6.2.1) - `patch-ap-like-activity-dispatcher` — `setObjectDispatcher(Like, …)` for dereferenceable like URLs (AP §3.1) - `patch-ap-url-lookup-api-like` — `/api/ap-url` returns `likeOf` URL for AP-likes - `patch-ap-remove-federation-diag` — removed verbose federation diagnostics inbox log - `patch-ap-normalize-nested-tags` — `cat.split("/").at(-1)` to strip nested tag prefixes - `patch-ap-object-url-trailing-slash` — trailing-slash normalisation on AP object URLs (3 orphan scripts not in `package.json`) - `patch-ap-og-image` — OG image in AP objects (orphan; feature remains undeployed) `patch-ap-skip-draft-syndication` kept — draft guard in `syndicate()` not yet in fork. **chore(deps): sync activitypub fork with upstream post-3.8.1** (`a37bece` in svemagie/indiekit-endpoint-activitypub) Four upstream fixes merged since 3.8.1, plus resolution of merge artifacts introduced by the upstream sync: - `9a0d6d20`: serve AP JSON for actor URLs received without an explicit `text/html` Accept header — fixes content negotiation for clients that omit Accept - `4495667e`: remove RSA Multikey from `assertionMethod` in the actor document — was causing tags.pub signature verification failures - `c71fd691`: direct follow workaround for tags.pub `identity/v1` JSON-LD context rejection — tags.pub rejects the W3C identity context on incoming follows; new `lib/direct-follow.js` sends follows without that context - Merge artifacts removed: duplicate `import { getActorUrlFromId }` in `accounts.js`, duplicate `const cachedUrl` declaration in `resolveActorUrl`, and a stray extra `import { remoteActorId }` in `account-cache.js` — all introduced when cherry-picked commits were merged back against upstream's copy of the same changes ### 2026-03-21 **chore(deps): merge upstream activitypub v3.7.1–v3.7.5 into fork** (`97a902b` in svemagie/indiekit-endpoint-activitypub) All five 3.7.x releases published upstream on 2026-03-21: - `lookupWithSecurity` is now async with a signed→unsigned fallback — servers like tags.pub that return 400 on signed GETs now resolve correctly instead of returning null - `enrichAccountStats()` (new `lib/mastodon/helpers/enrich-accounts.js`): enriches embedded account objects in timeline responses with real follower/following/post counts resolved via Fedify. Fixes 0/0/0 counts in Phanpy, which never calls `/accounts/:id` and trusts embedded data - Status content processing: `processStatusContent()` linkifies bare URLs and converts `@user@domain` mentions to `` links; `extractMentions()` populates the `mentions` array. Timeline date lookup now handles both `.000Z` and bare `Z` ISO suffixes - `/api/v1/relationships`: `domain_blocking` is now computed from `ap_blocked_servers` instead of always returning `false`; `resolveActorUrl` falls back to the account cache for timeline-author resolution - `/api/v1/domain_blocks`: returns real blocked server hostnames from `ap_blocked_servers` instead of `[]` - Federation management dashboard: new Moderation section listing blocked servers, blocked accounts, and muted accounts with timestamps **chore(deps): update activitypub fork to v3.6.8** (`fad383dfe`) Pulls the merged upstream `feat/mastodon-client-api` branch into svemagie/indiekit-endpoint-activitypub (`f029c31`). Ships a full Mastodon Client API compatibility layer (`lib/mastodon/`), 13 additional locale files, and builds `signatureTimeWindow`/`allowPrivateAddress` directly into `federation-setup.js` — `patch-ap-allow-private-address` now cleanly detects "already up to date". **fix(activitypub): serve AP-likes with canonical id and proper Like dispatcher** (`99d2e380`) Replaces the fake-Note approach with strict AP protocol compliance. Four new patch scripts: - `patch-ap-like-note-dispatcher`: reverts the fake-Note block - `patch-ap-like-activity-id`: adds canonical `id` URI to Like activities (AP §6.2.1) - `patch-ap-like-activity-dispatcher`: registers `setObjectDispatcher(Like, …)` so `/activitypub/activities/like/{id}` is dereferenceable (AP §3.1) - `patch-ap-url-lookup-api-like`: `/api/ap-url` now returns the `likeOf` URL for AP-likes so the "Also on: Fediverse" widget's `authorize_interaction` flow opens the original post on the remote instance **fix(activitypub): add Like vocab import in activity dispatcher patch** (`535e6f5e`) On fresh installs where the old wrong patch was never applied, `Like` was absent from the `@fedify/fedify/vocab` import block, causing a `ReferenceError` at startup. The dispatcher patch now adds `Like` to the import if missing. **fix(syndicate): normalize syndication property to array before dedup check** (`34d5fde5`) Micropub's `replaceEntries()` stores single-value arrays as plain strings. Spreading a string into `[...str]` gives individual characters, so `hasSyndicationUrl()` never matched and `alreadySyndicated` was always false — causing re-syndication on every webhook trigger. Fix: use `[].concat()` which safely handles both string and array values. **feat(deploy): trigger syndication webhook after successful deployment** (`b16c60ad`) Added a `workflow_dispatch`-compatible step to `.github/workflows/deploy.yml` that fires a configurable webhook URL after a successful deploy. Subsequently reverted (`9668485b`) and moved to the blog repo. **fix(activitypub): remove federation-diag inbox logging** (`109d39dd`) New `patch-ap-remove-federation-diag.mjs` strips the verbose federation diagnostics log added during debugging. **chore: silence github contribution log** (`25488257`) New `patch-endpoint-github-contributions-log.mjs` suppresses the noisy per-contribution log line from the GitHub store endpoint. --- ### 2026-03-20 **fix(ap): fix OG image not included in ActivityPub activities** The fork's OG image code expected date-based URLs (`/articles/YYYY/MM/DD/slug/`) but this blog uses flat URLs (`/articles/slug/`). The regex never matched so no `image` property was set and Mastodon/fediverse clients showed no preview card. Added `patch-ap-og-image.mjs` which extracts the slug from the URL's last path segment and constructs `/og/{slug}.png` — the actual Eleventy OG filename format (e.g. `/og/2615b.png`). **fix(ap): include commentary in repost ActivityPub activities** (`b53afe2e`) Reposts with a body were silently broken in two ways: (1) `jf2ToAS2Activity()` always emitted a bare `Announce` pointing at an external URL that doesn't serve AP JSON, so Mastodon dropped the activity from followers' timelines; (2) `jf2ToActivityStreams()` hard-coded Note content to `🔁 `, ignoring `properties.content`. New `patch-ap-repost-commentary.mjs` (4 targeted replacements): skips the `Announce` early-return when commentary is present and falls through to `Create(Note)` instead; formats Note as `\n\n🔁 `; extracts commentary in the content-negotiation path. Pure reposts (no body) keep the `Announce` behaviour unchanged. **chore(ai): remove custom AI patches superseded by upstream endpoint-posts@beta.44** (`fe0f347e`) Removed 6 patch scripts now handled natively by upstream: - `patch-preset-eleventy-ai-frontmatter` — upstream writes AI frontmatter with hyphenated keys natively - `patch-endpoint-posts-ai-cleanup` — upstream beta.44 removes empty AI fields natively - `patch-endpoint-posts-ai-fields` — upstream beta.44 has AI form UI inline in `post-form.njk` - `patch-micropub-ai-block-resync` — one-time stale-block migration, no longer relevant - `patch-endpoint-posts-prefill-url` — upstream beta.44 has native prefill from query params - `patch-endpoint-posts-search-tags` — upstream beta.44 has native search/filter/sort UI Also bumped `@rmdes/indiekit-endpoint-posts` beta.25→beta.44 and removed `camelCase` AI field names from all `postTypes.fields` in `indiekit.config.mjs`. **fix(webmention): livefetch evolution v3→v5** (`11d600058`, `7f9f02bc3`, `17b93b3a2`) Three successive fixes to the webmention sender livefetch patch, driven by split-DNS and jail networking constraints: - **v3** (`11d600058`): Send `Host: blog.giersig.eu` on internal fetches so nginx routes to the correct vhost; add `fetchUrl` diagnostics and response body preview on h-entry check failure - **v4** (`7f9f02bc3`): Remove `INTERNAL_FETCH_URL` rewrite for live page fetches — post URLs require authentication on the internal nginx vhost (returns login page). Fetch from `postUrl` (public URL) directly. Add `WEBMENTION_LIVEFETCH_URL` as an opt-in override - **v5** (`17b93b3a2`): Replace live page fetch entirely with a synthetic h-entry HTML snippet built from `post.properties` stored in MongoDB (`in-reply-to`, `like-of`, `bookmark-of`, `repost-of`, `content.html`). No network fetch required — eliminates all split-DNS / auth reliability issues **fix: h-entry double-quote typo in livefetch patch** (`750267b17`) Removed a stray extra closing quote (`h-entry""`) introduced in the v2 patch, which broke the string match on case-sensitive systems. --- ### 2026-03-27 **fix(webmention): livefetch v6 — synthetic h-entry from stored properties, no live fetch** Root cause of persistent webmention failures: the livefetch patch was fetching the live page through nginx port 80, which `000-defaults.conf` answered with HTTP 444 (silent connection drop) for any request whose `Host` header didn't match a known `server_name`. The poller sent `Host: 10.100.0.10` (the nginx jail IP), which matched nothing. v6 eliminates the live-page fetch entirely. Instead, it reads the stored post properties from MongoDB and builds a minimal synthetic `
` with anchor tags for each microformat property (`in-reply-to`, `like-of`, `bookmark-of`, `repost-of`, `syndication`) plus the stored `content.html`. This is reliable, fast, and requires no networking. Additional changes: - livefetch v6: adds `console.log` per post showing which properties produced links — makes future debugging possible without server access - livefetch v6: upgrades from any prior version (v1–v5) in-place via per-version end-marker detection - retry patch: regex now matches `[patched:livefetch]` and `[patched:livefetch:vN]` for all versions - reset-stale v11: bumped to retry posts stuck before v6 deployment - start.sh: poller now uses `INDIEKIT_DIRECT_URL=http://INDIEKIT_BIND_HOST:PORT` instead of `INTERNAL_FETCH_URL` (nginx); poller was timing out for 180s every restart due to the 444 responses **chore: `sharp_from_source=true` in `.npmrc`** Builds the `sharp` native module from source for FreeBSD compatibility (no prebuilt binary available). ### 2026-03-19 **feat: deliver likes as bookmarks, revert announce cc, add OG images** (`45f8ba9` in svemagie/indiekit-endpoint-activitypub) Likes are now sent as Create/Note with bookmark-style content (🔖 emoji + URL + `#bookmark` tag) instead of Like activities — ensures proper display on Mastodon. Announce activities reverted to upstream @rmdes addressing (`to: Public` only, no `cc: followers`). Both plain JSON-LD and Fedify Note/Article objects now include a per-post OG image derived from the post URL pattern. Removed unused `patch-ap-like-announce-addressing.mjs`. **feat: add soft-delete filter and content-warning support to blog theme** (`d9ac9bf` in svemagie/blog) Posts with `deleted: true` are now excluded from all Eleventy collections (supports AP soft-delete). Posts with `contentWarning`/`content_warning` frontmatter show a collapsible warning on post pages and a warning label (hiding content + photos) on listing pages. **chore: merge upstream rmdes:main v2.13.0–v2.15.4 into fork** (`b99f5fb`) Merged 15 upstream commits adding: manual follow approval, custom emoji, FEP-8fcf/fe34 compliance (v2.13.0), server blocking, Redis caching, key refresh, async inbox queue (v2.14.0), outbox failure handling with strike system, reply chain forwarding, reply intelligence in reader (v2.15.0–v2.15.4), CW `content-warning` property, soft-delete filtering, `as:Endpoints` type stripping. Preserved our DM compose path, Like/Announce addressing, draft/unlisted outbox guards. **chore: remove 5 obsolete AP patches** — `patch-ap-object-url-trailing-slash`, `patch-ap-normalize-nested-tags`, `patch-ap-like-announce-addressing`, `patch-inbox-skip-view-activity-parse`, `patch-inbox-ignore-view-activity` are now baked into the fork source. **fix: update patch-ap-allow-private-address for v2.15 comment style** — The upstream `createFederation` block changed its comment format; updated the patch to match. **fix: patch webmention-sender syntax error** (`c6b0e702`) `@rmdes/indiekit-endpoint-webmention-sender@1.0.8` shipped with a typo: `_html.includes("h-entry"")` — the extra closing quote causes a `SyntaxError` at startup and prevents the background sync from ever running. New `patch-webmention-sender-hentry-syntax.mjs` fixes the typo before any other webmention-sender patches run. **fix: livefetch v2 patch improvements** (`711958b8`) - retry patch: silently skips when livefetch v2 marker is present (no more misleading "target snippet not found (package updated?)" noise on every startup) - livefetch: match `h-entry"` or `h-entry ` instead of bare `h-entry` to avoid false positives from body text containing the string - reset-stale: update comment to reference livefetch v2 as the patch that prevents recurrence **fix(webmention): validate live page has .h-entry before processing** (`c4f654fe`) Root cause of stuck webmentions: the livefetch got a 200 OK response that was actually an nginx 502 or login-redirect HTML page. No `.h-entry` → `extractLinks` found 0 links → post permanently marked as sent with empty results. - livefetch upgraded to v2: checks `_html.includes("h-entry\"")` before using the response; rejects error pages instead of processing them; no fallback to stored content (which lacks microformat links for likes/reposts/bookmarks); detects and upgrades v1 patch in-place - reset-stale bumped to v9: broadened MongoDB `$or` query to match both old numeric-zero format and new v1.0.6+ empty-array format (`$size: 0`) - retry patch: now silently skips when livefetch v2 marker is present (no more misleading "target snippet not found (package updated?)" noise on every startup) - start.sh readiness check: now polls `/webmention-sender/api/status` (plugin's own endpoint) instead of `/status` (bare Express), ensuring MongoDB collections and plugin routes are fully initialised before the first poll ### 2026-03-14 **chore: upgrade checkout and setup-node actions to v4** (`d3fb055`) Upgraded `actions/checkout` and `actions/setup-node` from v3 to v4. Addresses the Node.js 20 deprecation warning ahead of the June 2026 forced migration to Node.js 24. **chore: update comments-locales patch for 1.0.10 template, drop livefetch patch** (`53b40a5`) Updated `patch-endpoint-comments-locales` to match the rewritten `comments.njk` template (Nunjucks macros + `badge()`). Removed obsolete locale keys and deleted the orphaned `patch-webmention-sender-livefetch` script. **chore: update @indiekit/* to beta.27, bump endpoint-comments and webmention-sender** (`53bb7d3`) - `@indiekit/indiekit`, `@indiekit/store-github`: beta.25 → beta.27 - `@rmdes/indiekit-endpoint-comments`: 1.0.0 → 1.0.10 - `@rmdes/indiekit-endpoint-webmention-sender`: 1.0.6 → 1.0.7 **fix: buffer ActivityPub body before checking for PeerTube View activities** (`314a085`) Express's JSON body parser ignores `application/activity+json`, so `req.body` was always undefined and the PeerTube View guard never fired. Now manually buffers and parses the raw stream for `activity+json`/`ld+json` POSTs before the type check. **chore: remove Mastodon syndicator and related patches** (`3708dd9`) Removed the Mastodon syndicator package, config vars, patch script, and `.env` example entries. The blog is now a native ActivityPub actor. **fix: skip PeerTube View activities before Fedify JSON-LD parse** (`296745f`) Added an early guard in `createFedifyMiddleware` that short-circuits any POST with `body.type === "View"` and returns 200 immediately, preventing Fedify from crashing on PeerTube's non-standard View activities. **fix: silently ignore PeerTube View activities in ActivityPub inbox** (`f004ecd`) Added a no-op `.on(View, ...)` inbox handler to suppress noisy "Unsupported activity type" errors from PeerTube's per-watch broadcasts. **feat: add gardenStage and ai fields to all post type presets** (`304c75f`) - `gardenStage`: added to all post types - `aiTextLevel`, `aiCodeLevel`, `aiTools`, `aiDescription`: extended to all content post types (bookmark, repost, photo, reply, page) **fix: register bluesky cursor-fix patch in postinstall and serve scripts** (`3781503`) Ensured the Bluesky cursor-fix patch runs during both `postinstall` and `serve`. **fix: clear stale Bluesky polling cursor to restore interaction ingestion** (`655bc73`) Cleared a stale cursor that was blocking new Bluesky interactions from being ingested. **fix: filter out self-interactions from own Bluesky account** (`4f1440a`, `f8f595f`) Filtered out likes, reposts, and replies from the blog's own Bluesky account to prevent self-syndication loops. **fix: scope webmention link extraction to .h-entry not .e-content** (`b632af9`) `u-in-reply-to`, `u-like-of`, `u-repost-of` etc. are rendered before `.e-content`, not inside it. Scoping to `.h-entry .e-content` caused them to be missed. Bumped reset-stale migration to v3 to retry affected posts. **fix: improve microsub feed discovery via `` tags** (`3ca9200`) `fetchAndParseFeed` now calls `discoverFeeds()` on the fetched HTML before probing common paths, using any typed RSS/Atom/JSONFeed `` it finds. **fix: pre-fill reference URL when creating post from /news entry** (`0dc71d1`) `postData.create` previously only read `request.body`, ignoring query params. Now seeds `properties` from `?url=`/`?name=` per post type: `like-of`, `bookmark-of`, `in-reply-to`, `repost-of`. **fix: post edit 404 — query micropub source by _id not paginated scan** (`1d28df8`) `getPostProperties` was scanning the 40 most-recent posts for a uid match, returning 404 for any older post. Fixed by patching the micropub query controller to perform a direct `findOne({ _id: getObjectId(uid) })` when `?q=source&uid=` is present.