# CLAUDE.md — indiekit-blog Personal [Indiekit](https://getindiekit.com/) deployment for [blog.giersig.eu](https://blog.giersig.eu). ## Always read memory files first Before investigating or modifying anything: | File | When to read | |---|---| | [`memory/project_activitypub.md`](memory/project_activitypub.md) | Any AP / fediverse / reply threading work | | [`memory/project_architecture.md`](memory/project_architecture.md) | Server layout, MongoDB, nginx, internal URLs | | [`memory/feedback_patches.md`](memory/feedback_patches.md) | Writing or debugging patch scripts | --- ## Running ```sh npm run serve # preflights + all patches + start Indiekit npm run postinstall # re-apply patches after npm install ``` Never start with `node` directly — patches must run first. --- ## Patch system All node_modules fixes live in `scripts/patch-*.mjs`. Both `postinstall` and `serve` run them in order. ### Pattern ```js const MARKER = "// [patch] my-patch-name"; const OLD_SNIPPET = `exact source text (spaces not tabs, exact line endings)`; const NEW_SNIPPET = `replacement text ${MARKER}`; // 1. Read file — skip if MARKER already present // 2. Warn if OLD_SNIPPET not found (upstream changed) // 3. Replace + writeFile ``` ### Rules - Include **both** candidate paths: `node_modules/@rmdes/...` and `node_modules/@indiekit/indiekit/node_modules/@rmdes/...` - Escape template literals: `` \` `` and `\${}` - Append new AP patches **after** `patch-ap-federation-bridge-base-url` in both `postinstall` and `serve` - `patch-microsub-reader-ap-dispatch` is `serve`-only — check both scripts for microsub patches - After writing a patch script, run it immediately (`node scripts/patch-*.mjs`) to verify it applies cleanly --- ## Architecture — things that affect code ### Two-jail setup ``` Internet → nginx (web jail 10.100.0.10) → Indiekit (node jail 10.100.0.20:3000) ``` The node jail **cannot reach its own public HTTPS URL**. Internal self-fetches must use `INTERNAL_FETCH_URL=http://10.100.0.20:3000` directly. All such fetches go through `_toInternalUrl()` (injected by `patch-micropub-fetch-internal-url`). ### nginx / Fedify nginx must forward `Host: blog.giersig.eu` and `X-Forwarded-Proto: https` or AP lookups 302-redirect to the login page. See `patch-ap-federation-bridge-base-url`. `createFederation()` requires `allowPrivateAddress: true` (blog resolves to a LAN IP) and `signatureTimeWindow: { hours: 12 }` (Mastodon retries with old signatures). ### MongoDB collections | Collection | Contents | |---|---| | `posts` | Micropub post data — `properties.url` is the lookup key | | `ap_timeline` | AP posts (inbound + outbound) — keyed by `uid` | | `ap_notifications` | Mentions, replies, likes, boosts | | `ap_followers` / `ap_following` | Actor URLs | | `ap_activities` | Outbound/inbound activity log | | `ap_profile` | Own actor (name, icon, url) | | `ap_interactions` | Own likes/boosts | | `ap_keys` | RSA + Ed25519 key pairs | | `ap_featured` | Pinned posts | --- ## Post type discovery `getPostType(postTypes, properties)` checks **key presence only** — value doesn't matter: | Key present | Type | Saved at | |---|---|---| | `in-reply-to` | `reply` | `/replies/{slug}/` | | `like-of` | `like` | `/likes/{slug}/` | | `repost-of` | `repost` | `/reposts/{slug}/` | | `photo` | `photo` | `/photos/{slug}/` | | _(none)_ | `note` | `/notes/{slug}/` | If `in-reply-to` is silently absent, the post becomes a note **with no error**. This is the most common threading bug root cause. --- ## Reply threading — compose paths Three paths, different syndication mechanics: | Path | AP checkbox mechanism | |---|---| | AP reader `/activitypub/admin/reader/compose` | `target.defaultChecked = target.checked === true` *(patched by `patch-ap-compose-default-checked`)* | | Microsub reader `/microsub/admin/reader/compose` | `target.checked` from Micropub `q=config` — already `true` for AP syndicator | | Mastodon client API `POST /api/v1/statuses` | `mp-syndicate-to` hardcoded to `publicationUrl` — always AP | The AP reader template uses `target.defaultChecked`, **not** `target.checked`. These are different fields. ### ap_timeline insertion timing Own posts reach `ap_timeline` via two paths: - **Mastodon API**: inserted immediately after `postContent.create()` *(patched by `patch-ap-mastodon-reply-threading`)* - **Micropub + syndication webhook**: inserted by syndicator after Eleventy build (30–120 s) Any new code path that creates posts should insert to `ap_timeline` immediately — otherwise `in_reply_to_id` lookups fail during the build window. ### Status ID format `encodeCursor(published)` = ms-since-epoch string. `findTimelineItemById` resolves this with a ±1 s range query using MongoDB `$dateFromString` to handle TZ-offset ISO strings. --- ## ActivityPub syndicator `syndicator.syndicate(properties)` does **not** filter by post type. A note and a reply both become `Create(Note)`. The difference is whether `inReplyTo` is set (from `properties["in-reply-to"]`). **Deduplication** (`patch-ap-syndicate-dedup`): 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. Prevents duplicate activities from CI webhooks triggering syndication twice (the Gitea commit that saves the syndication URL triggers a second build → second webhook call). **Delete propagation** (`patch-micropub-delete-propagation` + `patch-bluesky-syndicator-delete`): `action=delete` in Micropub now iterates `publication.syndicationTargets` and calls `syndicator.delete(url, syndication)` fire-and-forget for any syndicator that exposes `.delete()`. The AP syndicator broadcasts a `Delete(Note)` via `broadcastDelete(url)`. The Bluesky syndicator deletes the bsky.app post via `com.atproto.repo.deleteRecord`, resolving the URL from `_deletedProperties`. JF2 → AS2 mapping: | Post type | Activity | Notes | |---|---|---| | `note` / `reply` | `Create(Note)` | reply has `inReplyTo` | | `like` | `Create(Note)` | bookmark framing (🔖 emoji) | | `repost` | `Announce` | | | `article` | `Create(Article)` | has `name` | Visibility: | Value | `to` | `cc` | |---|---|---| | `public` | `as:Public` | followers | | `unlisted` | followers | `as:Public` | | `followers` | followers | — | --- ## Fork dependencies ```sh # Pull latest commit from a fork: npm install git+https://gitea.giersig.eu/svemagie/ npm install git+https://gitea.giersig.eu/svemagie/indiekit-endpoint-activitypub ``` | Package | Fork | |---|---| | `@rmdes/indiekit-endpoint-activitypub` | `git+https://gitea.giersig.eu/svemagie/indiekit-endpoint-activitypub` | | `@rmdes/indiekit-endpoint-blogroll` | `git+https://gitea.giersig.eu/svemagie/indiekit-endpoint-blogroll` | | `@rmdes/indiekit-endpoint-microsub` | `git+https://gitea.giersig.eu/svemagie/indiekit-endpoint-microsub` | | `@rmdes/indiekit-endpoint-youtube` | `git+https://gitea.giersig.eu/svemagie/indiekit-endpoint-youtube` | --- ## Debugging — starting points | Symptom | First check | |---|---| | Reply created as "note" not "reply" | Is `in-reply-to` in the Micropub request? Check: form hidden field, `submitComposeController`, `findTimelineItemById` return value, `formEncodedToJf2` | | Reply not federated to AP | Is `mp-syndicate-to` set? Check `target.defaultChecked` / `target.checked`, `getSyndicateToProperty` in `jf2.js` | | AP lookup returns 302 / auth redirect | nginx not forwarding `Host`/`X-Forwarded-Proto` — see `patch-ap-federation-bridge-base-url` | | `findTimelineItemById` returns null | Item not yet in `ap_timeline` (build not finished) or TZ-offset date mismatch — `$dateFromString` range query should catch offsets | | Favourite/reblog hangs in Mastodon client | `resolveAuthor` timeout — `Promise.race` 5 s cap should prevent this | | "Empty reply from server" on webmention poller | Poller routing through nginx (returns 444 for wrong Host) — must use `INDIEKIT_DIRECT_URL` | | HTTP Signature 401 errors on all inbound activities | nginx forwarding wrong `Host` header — fixed by `patch-ap-signature-host-header` (overrides to `blog.giersig.eu`) | | HTTP Signature verify errors flooding logs for deleted/migrated actors | Expected noise — `patch-ap-inbox-delivery-debug` suppresses to `fatal`; real errors surface at `error` level | | "OAuth callback failed. Missing parameters." | `state` parameter not echoed — fixed in fork (`b54146c`) | | AP object 410 / Tombstone | Post was deleted — correct, served by FEP-4f05 | --- ## Environment variables | Var | Purpose | |---|---| | `AP_HANDLE` | AP handle (`svemagie`) | | `AP_ALSO_KNOWN_AS` | Migration alias (`https://troet.cafe/users/svemagie`) | | `AP_LOG_LEVEL` | Fedify log level (`info` default; `debug` for delivery tracing) | | `AP_DEBUG` | `1` to enable Fedify debug dashboard at `/activitypub/__debug__/` | | `PUBLICATION_URL` | Canonical blog URL | | `INDIEKIT_URL` | Application URL (same as publication URL) | | `INTERNAL_FETCH_URL` | Direct node jail URL for self-fetches (`http://10.100.0.20:3000`) | | `INDIEKIT_BIND_HOST` | Jail IP for webmention poller direct connect | | `REDIS_URL` | Redis for AP message queue + KV (production; without this, queue lost on restart) | | `MONGO_HOST` / `MONGO_URL` | MongoDB connection | | `GH_CONTENT_TOKEN` | Gitea PAT for writing posts to the indiekit-blog repo | | `SECRET` | JWT signing secret (webmention poller auth) | --- ## Content store (Gitea) `@indiekit/store-github` is pointed at the self-hosted Gitea instance instead of GitHub. Key config in `indiekit.config.mjs`: ```js "@indiekit/store-github": { baseUrl: giteaBaseUrl, // GITEA_BASE_URL from .env user: process.env.GITEA_CONTENT_USER, repo: process.env.GITEA_CONTENT_REPO, branch: "main", token: githubContentToken, // GH_CONTENT_TOKEN from .env } ``` **`GITEA_BASE_URL`** must end with a trailing slash: `http://10.100.0.90:3000/api/v1/` Without it, `new URL(apiPath, baseUrl)` silently strips the `v1` segment → 404 on all writes. **`GH_CONTENT_TOKEN`** — the Gitea PAT for `svemagie`. `start.sh` rejects startup if neither `GH_CONTENT_TOKEN` nor `GITHUB_TOKEN` is present. The token must have repo read/write scope on `giersig.eu/indiekit-blog`. **`GITEA_CONTENT_USER`** = `giersig.eu` (the org, not the personal username) **`GITEA_CONTENT_REPO`** = `indiekit-blog` --- ## Micropub → Gitea build dispatch Gitea Contents API commits (what `store-github` does) do **not** trigger `on: push` CI workflows. `patch-micropub-gitea-dispatch-conditional.mjs` patches the Micropub endpoint to fire a `workflow_dispatch` event to `giersig.eu/indiekit-blog` after each create/update, so the blog rebuilds immediately after a post is published. --- ## Pushing changes from the server The node jail shell is tcsh, which mangles multi-line `echo`/`printf` and inline heredocs. To push file changes to Gitea from the server, use a Python script: ```python python3 << 'PYEOF' import urllib.request, json, base64 TOKEN = "your-gitea-pat" REPO = "giersig.eu/indiekit-blog" PATH = ".github/workflows/deploy.yml" BASE = "http://10.100.0.90:3000/api/v1" # 1. Get current SHA req = urllib.request.Request(f"{BASE}/repos/{REPO}/contents/{PATH}", headers={"Authorization": f"token {TOKEN}"}) info = json.loads(urllib.request.urlopen(req).read()) sha = info["sha"] # 2. Read new content and encode with open("/path/to/local/file") as f: content = base64.b64encode(f.read().encode()).decode() # 3. PUT new content data = json.dumps({"message": "update file", "content": content, "sha": sha}).encode() req2 = urllib.request.Request(f"{BASE}/repos/{REPO}/contents/{PATH}", data=data, method="PUT", headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}) urllib.request.urlopen(req2) print("done") PYEOF ``` Always generate base64 from the actual file — never copy b64 strings from session history (they can be silently corrupted by terminal line wrapping). For Node.js scripts passed via `bastille cmd node sh -c '...'`, use base64 to avoid quoting issues: ```sh # On local machine: encode the script cat script.js | base64 | tr -d '\n' # On server: decode and run echo | b64decode -r > /tmp/script.js && node /tmp/script.js ```