15 KiB
CLAUDE.md — indiekit-blog
Personal Indiekit deployment for svemagie.net.
Always read memory files first
Before investigating or modifying anything:
| File | When to read |
|---|---|
memory/project_activitypub.md |
Any AP / fediverse / reply threading work |
memory/project_architecture.md |
Server layout, MongoDB, nginx, internal URLs |
memory/feedback_patches.md |
Writing or debugging patch scripts |
Running
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
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/...andnode_modules/@indiekit/indiekit/node_modules/@rmdes/...(for@indiekit/frontendusenode_modules/@indiekit/frontend/...andnode_modules/@indiekit/indiekit/node_modules/@indiekit/frontend/...) - Escape template literals:
\`and\${} - Append new AP patches after
patch-ap-federation-bridge-base-urlin bothpostinstallandserve patch-microsub-reader-ap-dispatchisserve-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)
→ Gitea (gitea jail 10.100.0.90: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).
Hairpin NAT — jails cannot reach git.wildwuchs.work
pf's rdr rules only fire on $ext_if (vtnet0). Jail-originated traffic to the public IP bypasses RDR entirely → git.wildwuchs.work:443 unreachable from any jail. This breaks:
actions/checkout@v4in the act_runner (gitea-Jail) — can't clone the reponpm cifetchinggit+https://git.wildwuchs.work/...packages (node-Jail)
Solution: per-user .gitconfig URL rewrite in each affected jail:
[url "http://10.100.0.90:3000/"]
insteadOf = https://git.wildwuchs.work/
| Jail | File on host |
|---|---|
gitea (act_runner, user git) |
/usr/local/bastille/jails/gitea/root/usr/local/git/.gitconfig |
node (indiekit, user indiekit) |
/usr/local/bastille/jails/node/root/usr/local/indiekit/.gitconfig |
/etc/hosts in the gitea-Jail also has 10.100.0.90 git.wildwuchs.work.
Adding a new jail: add the same [url] block to the user's .gitconfig at the host path above.
nginx / Fedify
nginx must forward Host: svemagie.net 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 bypatch-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
# Pull latest commit from a fork:
npm install git+https://git.wildwuchs.work/svemagie/<package-name>
npm install git+https://git.wildwuchs.work/svemagie/indiekit-endpoint-activitypub
| Package | Fork |
|---|---|
@rmdes/indiekit-endpoint-activitypub |
git+https://git.wildwuchs.work/svemagie/indiekit-endpoint-activitypub |
@rmdes/indiekit-endpoint-blogroll |
git+https://git.wildwuchs.work/svemagie/indiekit-endpoint-blogroll |
@rmdes/indiekit-endpoint-microsub |
git+https://git.wildwuchs.work/svemagie/indiekit-endpoint-microsub |
@rmdes/indiekit-endpoint-youtube |
git+https://git.wildwuchs.work/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 |
| Liked/bookmarked posts disappear from Favourites/Bookmarks over time | Daily timeline cleanup was deleting them — fixed by patch-ap-interactions-cleanup-preserve (exempts items present in ap_interactions) |
| Favourited/reblogged state wrong on account statuses timeline | accounts.js added ix.objectUrl to the Sets instead of item.uid — fixed by patch-ap-interactions-accounts-uid |
| Liked posts show as not-liked in thread context (ancestors/descendants) | Context endpoint used empty interaction Sets — fixed by patch-ap-interactions-context-state |
| "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 svemagie.net) |
| HTTP Signature verify errors flooding logs for deleted/migrated actors | Expected noise — patch-ap-inbox-delivery-debug (in patch-ap-federation-infra.mjs) suppresses both ["fedify","federation","inbox"] and ["fedify","runtime","docloader"] to lowestLevel: "fatal". Current marker: ap-inbox-delivery-debug-A-fatal |
| Mastodon client (Phanpy, etc.) gets 401 on all authenticated endpoints ~10 min after login | OAuth access token inherited the auth code's 10-min expiresAt — fixed by patch-ap-oauth-token-expiry-fix ($unset: { expiresAt } during code exchange) |
| Mastodon client gets 401 on all requests immediately (not just after 10 min) | "Autorisiertes Abrufen erfordern" (authorized fetch / secure mode) is enabled — unsigned GET requests to actor/collections are rejected. Error message "access token is invalid" is misleading; it comes from the authorized-fetch layer, not OAuth. Fix: disable authorized fetch in AP admin settings. Trade-off: blocked servers can still fetch public posts, but this is acceptable for a public blog. |
| "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 |
| Tag autocomplete shows no suggestions | publication.categories is not configured — patch-micropub-category-from-posts overrides q=category to query MongoDB posts.distinct("properties.category") instead |
| Tag autocomplete dropdown not appearing while typing | patch-tag-input-autocomplete wires a <datalist> to the tag input; check that both patches applied (run them manually with node scripts/patch-*.mjs) |
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:
"@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 svemagie.net/indiekit-blog.
**GITEA_CONTENT_USER = svemagie.net (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 svemagie.net/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:
python3 << 'PYEOF'
import urllib.request, json, base64
TOKEN = "your-gitea-pat"
REPO = "svemagie.net/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:
# On local machine: encode the script
cat script.js | base64 | tr -d '\n'
# On server: decode and run
echo <b64> | b64decode -r > /tmp/script.js && node /tmp/script.js