12 KiB
CLAUDE.md — indiekit-blog
Personal Indiekit deployment for blog.giersig.eu.
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/... - 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)
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 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://gitea.giersig.eu/svemagie/<package-name>
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:
"@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:
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:
# 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