chore: remove internal planning docs and memory from git tracking
Deploy Indiekit Server / deploy (push) Successful in 1m27s

This commit is contained in:
Sven
2026-05-11 10:10:03 +02:00
parent e7d432a85f
commit e114109ff1
8 changed files with 0 additions and 1518 deletions
@@ -1,387 +0,0 @@
# Tag Entry Autocomplete Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the category/tag input in the Indiekit post compose form searchable by wiring a native datalist autocomplete to a MongoDB-backed `q=category` API endpoint.
**Architecture:** Two patch scripts following the project's marker pattern. Patch 1 overrides the Micropub `q=category` handler to query MongoDB `posts.distinct("properties.category")` instead of the empty config array. Patch 2 adds a `<datalist>` and debounced fetch to the `<tag-input-field>` web component.
**Tech Stack:** Node.js ESM patch scripts, MongoDB `distinct()`, browser `fetch` + `<datalist>`, existing `queryConfig` utility.
---
## File Map
| File | Action | Purpose |
|---|---|---|
| `scripts/patch-micropub-category-from-posts.mjs` | **Create** | Backend patch — MongoDB distinct query for `q=category` |
| `scripts/patch-tag-input-autocomplete.mjs` | **Create** | Frontend patch — datalist autocomplete on tag input |
| `package.json` | **Modify** | Register both patches in `postinstall` and `serve` scripts |
Patch targets (read-only reference, patched in-place at startup):
- `node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js``case "categories":` inserted before `default:`
- `node_modules/@indiekit/frontend/components/tag-input/index.js` — datalist + event listeners appended before `return tagInput`
---
## Task 1: Write the backend patch script
**Files:**
- Create: `scripts/patch-micropub-category-from-posts.mjs`
- [ ] **Step 1: Create the patch script**
```js
/**
* Patch: make q=category return distinct tags from MongoDB posts collection.
*
* In queryController, line 28 rewrites q === "category" → "categories" before
* the switch, so the new case label must be "categories" (not "category").
* The default branch returns config.categories (always []) because
* publication.categories is not configured. This patch inserts a
* case "categories": before default: that queries the posts collection instead.
*
* postsCollection may be undefined (no MongoDB). The ternary provides a []
* fallback consistent with the guard pattern used throughout query.js.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const MARKER = "// [patch] micropub-category-from-posts";
const candidates = [
"node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js",
"node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js",
];
const OLD_SNIPPET = ` break;
}
default: {
// Query configuration value (can be filtered, limited and offset)
if (config[q]) {`;
const NEW_SNIPPET = ` break;
}
case "categories": { ${MARKER}
const cats = postsCollection
? (await postsCollection.distinct("properties.category")).filter(Boolean).sort()
: [];
response.json({
categories: queryConfig(cats, { filter: filter?.toLowerCase(), limit, offset }),
});
break;
}
default: {
// Query configuration value (can be filtered, limited and offset)
if (config[q]) {`;
async function exists(p) {
try { await access(p); return true; } catch { return false; }
}
let totalPatched = 0;
let totalChecked = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) continue;
totalChecked++;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
console.log(`[postinstall] patch-micropub-category-from-posts: already applied to ${filePath}`);
continue;
}
if (!source.includes(OLD_SNIPPET)) {
console.warn(`[postinstall] patch-micropub-category-from-posts: snippet not found in ${filePath} (upstream changed?)`);
continue;
}
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
await writeFile(filePath, updated, "utf8");
console.log(`[postinstall] Applied patch-micropub-category-from-posts to ${filePath}`);
totalPatched++;
}
if (totalChecked === 0) {
console.log("[postinstall] patch-micropub-category-from-posts: no target files found");
} else if (totalPatched === 0) {
console.log("[postinstall] patch-micropub-category-from-posts: already up to date");
} else {
console.log(`[postinstall] patch-micropub-category-from-posts: patched ${totalPatched} file(s)`);
}
```
- [ ] **Step 2: Run the patch script to verify it applies cleanly**
```bash
node scripts/patch-micropub-category-from-posts.mjs
```
Expected output: `Applied patch-micropub-category-from-posts to node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js`
If you see "snippet not found", the source file has changed upstream. Open `node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js`, find the `default:` block at the end of the switch, and update `OLD_SNIPPET` to match its actual content exactly (spaces, not tabs).
- [ ] **Step 3: Verify the patch landed correctly**
```bash
grep -A 10 "case .categories" node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js
```
Expected: the new `case "categories":` block appears before `default:`.
- [ ] **Step 4: Commit**
```bash
git add scripts/patch-micropub-category-from-posts.mjs
git commit -m "feat: patch q=category to return distinct tags from MongoDB posts"
```
---
## Task 2: Write the frontend patch script
**Files:**
- Create: `scripts/patch-tag-input-autocomplete.mjs`
- [ ] **Step 1: Create the patch script**
```js
/**
* Patch: add datalist autocomplete to the <tag-input-field> web component.
*
* Fetches /micropub?q=category&filter=<value> as the user types (debounced
* 300ms) and populates a native <datalist>. Selecting a suggestion fills the
* text field; the user confirms with comma or blur as usual.
*
* Datalist is explicitly cleared in keydown(Comma) and blur handlers because
* the existing handlers clear the field via programmatic .value = "" which
* does NOT fire the input event (HTML Living Standard).
*/
import { access, readFile, writeFile } from "node:fs/promises";
const MARKER = "// [patch] tag-input-autocomplete";
const candidates = [
"node_modules/@indiekit/frontend/components/tag-input/index.js",
"node_modules/@indiekit/indiekit/node_modules/@indiekit/frontend/components/tag-input/index.js",
];
const OLD_SNIPPET = ` // Capture any value in input not converted to tag (for example, by clicking
// outside component before pressing tab key) and add to list of tags.
$tagInputInput.addEventListener("blur", () => {
if ($tagInputInput.value) {
tagInput.addTag($tagInputInput.value, false);
$tagInputInput.value = "";
}
});
return tagInput;`;
const NEW_SNIPPET = ` // Capture any value in input not converted to tag (for example, by clicking
// outside component before pressing tab key) and add to list of tags.
$tagInputInput.addEventListener("blur", () => {
if ($tagInputInput.value) {
tagInput.addTag($tagInputInput.value, false);
$tagInputInput.value = "";
}
});
// Autocomplete: wire datalist to Micropub q=category endpoint ${MARKER}
const _micropubHref =
document.querySelector('link[rel="micropub"]')?.href ?? "/micropub";
const _datalistId =
"tag-suggestions-" + (this.$replacedInput?.getAttribute("name") ?? Math.random().toString(36).slice(2));
const _$datalist = document.createElement("datalist");
_$datalist.id = _datalistId;
this.appendChild(_$datalist);
$tagInputInput.setAttribute("list", _datalistId);
let _autocompleteTimer;
$tagInputInput.addEventListener("input", () => {
clearTimeout(_autocompleteTimer);
const _val = $tagInputInput.value.trim();
if (!_val) { _$datalist.replaceChildren(); return; }
_autocompleteTimer = setTimeout(async () => {
try {
const _res = await fetch(
\`\${_micropubHref}?q=category&filter=\${encodeURIComponent(_val)}\`
);
if (!_res.ok) return;
const _data = await _res.json();
_$datalist.replaceChildren(
...(_data.categories ?? []).map((_cat) => {
const _opt = document.createElement("option");
_opt.value = _cat;
return _opt;
})
);
} catch {}
}, 300);
});
// Clear datalist after tag confirmed via comma (programmatic clear doesn't fire input)
$tagInputInput.addEventListener("keydown", (_e) => {
if (_e.code === "Comma") _$datalist.replaceChildren();
});
// Clear datalist after tag confirmed via blur
$tagInputInput.addEventListener("blur", () => {
_$datalist.replaceChildren();
});
return tagInput;`;
async function exists(p) {
try { await access(p); return true; } catch { return false; }
}
let totalPatched = 0;
let totalChecked = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) continue;
totalChecked++;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
console.log(`[postinstall] patch-tag-input-autocomplete: already applied to ${filePath}`);
continue;
}
if (!source.includes(OLD_SNIPPET)) {
console.warn(`[postinstall] patch-tag-input-autocomplete: snippet not found in ${filePath} (upstream changed?)`);
continue;
}
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
await writeFile(filePath, updated, "utf8");
console.log(`[postinstall] Applied patch-tag-input-autocomplete to ${filePath}`);
totalPatched++;
}
if (totalChecked === 0) {
console.log("[postinstall] patch-tag-input-autocomplete: no target files found");
} else if (totalPatched === 0) {
console.log("[postinstall] patch-tag-input-autocomplete: already up to date");
} else {
console.log(`[postinstall] patch-tag-input-autocomplete: patched ${totalPatched} file(s)`);
}
```
- [ ] **Step 2: Run the patch script to verify it applies cleanly**
```bash
node scripts/patch-tag-input-autocomplete.mjs
```
Expected output: `Applied patch-tag-input-autocomplete to node_modules/@indiekit/frontend/components/tag-input/index.js`
If you see "snippet not found", open the target file and compare the actual closing of the `blur` handler and `return tagInput` — they must match `OLD_SNIPPET` byte-for-byte including spaces (not tabs).
- [ ] **Step 3: Verify the patch landed correctly**
```bash
grep -A 5 "tag-input-autocomplete" node_modules/@indiekit/frontend/components/tag-input/index.js
```
Expected: the datalist setup code appears before `return tagInput;`.
- [ ] **Step 4: Commit**
```bash
git add scripts/patch-tag-input-autocomplete.mjs
git commit -m "feat: patch tag-input component with datalist autocomplete"
```
---
## Task 3: Register both patches in package.json
**Files:**
- Modify: `package.json`
Both patches are safe for `postinstall` and `serve` — they are idempotent (marker check skips re-application).
- [ ] **Step 1: Add both patches to the end of the `postinstall` script**
In `package.json`, the `postinstall` script currently ends with:
```
node scripts/patch-ap-startup-gate-bypass.mjs
```
Append to the end of `postinstall` (after `patch-ap-startup-gate-bypass.mjs`):
```
&& node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs
```
- [ ] **Step 2: Add both patches to the `serve` script before the final node command**
In the `serve` script, `patch-ap-startup-gate-bypass.mjs` appears near the **start** (after preflights), not the end. The script currently ends with:
```
node scripts/patch-ap-oauth-token-expiry-fix.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs
```
Change it so the two new patches come between `patch-ap-oauth-token-expiry-fix.mjs` and the final `node --require` invocation:
```
node scripts/patch-ap-oauth-token-expiry-fix.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs
```
- [ ] **Step 3: Verify JSON is valid**
```bash
node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('OK')"
```
Expected: `OK`
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "chore: register tag autocomplete patches in postinstall and serve"
```
---
## Task 4: End-to-end verification
No automated test framework exists. Verify manually after `npm run serve`.
- [ ] **Step 1: Confirm backend endpoint returns tags**
```bash
# While the server is running, test with curl (replace token with a valid one,
# or just open this URL in the browser while logged into the admin UI):
curl -s "http://localhost:3000/micropub?q=category" | node -e "process.stdin|>require('stream/consumers').json()|>d=>console.log(JSON.stringify(d,null,2))" 2>/dev/null || \
curl -s "http://localhost:3000/micropub?q=category"
```
Expected: `{ "categories": ["photography", "travel", ...] }` — actual tags from your posts, sorted alphabetically.
- [ ] **Step 2: Confirm filter works**
Open in browser (while logged in):
```
http://localhost:3000/micropub?q=category&filter=photo
```
Expected: only tags containing "photo" (case-insensitive).
- [ ] **Step 3: Verify autocomplete in the compose UI**
1. Open `/posts/create` or the AP/Microsub compose form
2. Click the category/tag field
3. Type 23 characters of a known tag
4. Verify a dropdown appears with matching suggestions
5. Click a suggestion → text field fills with the tag name
6. Press comma → tag becomes a pill, dropdown clears
7. Type another tag partially → blur by clicking elsewhere → tag confirmed, dropdown clears
@@ -1,486 +0,0 @@
# AP Patch Consolidation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Consolidate 27 individual AP patch scripts into 6 concern-based scripts, removing 2 dead patches, to make `postinstall`/`serve` maintainable.
**Architecture:** Each consolidated script reads its target file(s) once, applies multiple patches in sequence using a shared `applyPatch()` helper, and writes once per file. Per-patch MARKER idempotency is preserved. Ordering within a script matters when one patch's output is the next patch's OLD_SNIPPET anchor (documented per script).
**Tech Stack:** Node.js ESM (`.mjs`), `node:fs/promises` — same as existing patch scripts. No new dependencies.
---
## File Map
**Create:**
- `scripts/patch-ap-syndication.mjs` — absorbs 4 patches on `syndicator.js`
- `scripts/patch-ap-federation-infra.mjs` — absorbs 5 patches + delete-fix Change A across `federation-bridge.js`, `federation-setup.js`, `index.js`
- `scripts/patch-ap-mastodon-statuses.mjs` — absorbs 5 patches on `statuses.js`
- `scripts/patch-ap-mastodon-accounts.mjs` — absorbs 2 patches on `resolve-account.js` only (the spec header mentions `accounts.js` but both absorbed patches target `resolve-account.js`; `accounts.js` interaction patch is in misc)
- `scripts/patch-ap-mastodon-notifications.mjs` — absorbs 1 patch on `notifications.js`
- `scripts/patch-ap-mastodon-misc.mjs` — absorbs 8 patches across compose, interactions, og-image, inbox handlers
**Modify:**
- `package.json` — replace 27 AP patch invocations with 6 consolidated ones in both `postinstall` and `serve`
**Delete:**
- `scripts/patch-ap-syndicate-dedup.mjs`
- `scripts/patch-ap-syndicate-skip-checkin.mjs`
- `scripts/patch-ap-syndicate-skip-draft.mjs`
- `scripts/patch-ap-syndicate-skip-unlisted.mjs`
- `scripts/patch-ap-federation-bridge-base-url.mjs`
- `scripts/patch-ap-signature-host-header.mjs`
- `scripts/patch-ap-inbox-delivery-debug.mjs`
- `scripts/patch-ap-inbox-publication-url.mjs`
- `scripts/patch-ap-webfinger-before-auth.mjs`
- `scripts/patch-ap-mastodon-reply-threading.mjs`
- `scripts/patch-ap-mastodon-status-id.mjs`
- `scripts/patch-ap-mastodon-delete-fix.mjs`
- `scripts/patch-ap-status-reply-id.mjs`
- `scripts/patch-ap-interactions-context-state.mjs`
- `scripts/patch-ap-actor-cache-await.mjs`
- `scripts/patch-ap-resolve-account-timeout-safe.mjs`
- `scripts/patch-ap-notifications-status-lookup.mjs`
- `scripts/patch-ap-compose-default-checked.mjs`
- `scripts/patch-ap-og-image.mjs`
- `scripts/patch-ap-repost-announce-fix.mjs`
- `scripts/patch-ap-interactions-send-guard.mjs`
- `scripts/patch-ap-interactions-cleanup-preserve.mjs`
- `scripts/patch-ap-interactions-accounts-uid.mjs`
- `scripts/patch-inbox-ignore-view-activity.mjs`
- `scripts/patch-inbox-skip-view-activity-parse.mjs`
- `scripts/patch-ap-account-lookup-cache-fallback.mjs` (dead — upstream fixed)
- `scripts/patch-ap-skip-draft-syndication.mjs` (dead — OLD_SNIPPET no longer matches)
---
## Shared Helper Pattern
Every consolidated script uses this structure. Copy it exactly — do not vary across scripts:
```js
import { access, readFile, writeFile } from "node:fs/promises";
const AP_BASE = "@rmdes/indiekit-endpoint-activitypub";
const AP_ROOTS = [
`node_modules/${AP_BASE}`,
`node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`,
];
function apPath(rel) {
return AP_ROOTS.map(r => `${r}/${rel}`);
}
async function fileExists(p) {
try { await access(p); return true; } catch { return false; }
}
/**
* Apply a single patch to a file.
* Returns: "applied" | "already_applied" | "snippet_not_found" | "file_not_found"
*/
async function applyPatch(filePath, marker, oldSnippet, newSnippet) {
if (!(await fileExists(filePath))) return "file_not_found";
const src = await readFile(filePath, "utf8");
if (src.includes(marker)) return "already_applied";
if (!src.includes(oldSnippet)) return "snippet_not_found";
await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8");
return "applied";
}
const SCRIPT = "patch-ap-<name>"; // change per script
const PATCHES = [
// { name, files: string[], marker, oldSnippet, newSnippet }
// NOTE: Order matters when one patch's newSnippet is the next patch's oldSnippet anchor
];
let total = 0;
for (const p of PATCHES) {
let done = false;
for (const f of p.files) {
const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet);
if (r === "applied") {
console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`);
total++; done = true; break;
} else if (r === "already_applied") {
console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`);
done = true; break;
} else if (r === "snippet_not_found") {
console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`);
}
}
if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`);
}
console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);
```
---
## Task 1: Create `patch-ap-syndication.mjs`
**Absorbs:** `patch-ap-syndicate-dedup`, `patch-ap-syndicate-skip-checkin`, `patch-ap-syndicate-skip-draft`, `patch-ap-syndicate-skip-unlisted`
**Target file:** `lib/syndicator.js`
**ORDERING CONSTRAINT:** dedup → checkin → draft → unlisted. Each patch's `newSnippet` is the next patch's `oldSnippet` anchor. Wrong order = silent skip.
- [ ] **Read all 4 source scripts** to extract MARKER / OLD_SNIPPET / NEW_SNIPPET for each:
- `scripts/patch-ap-syndicate-dedup.mjs`
- `scripts/patch-ap-syndicate-skip-checkin.mjs`
- `scripts/patch-ap-syndicate-skip-draft.mjs`
- `scripts/patch-ap-syndicate-skip-unlisted.mjs`
- [ ] **Create `scripts/patch-ap-syndication.mjs`** using the shared helper pattern above.
- Set `SCRIPT = "patch-ap-syndication"`
- All 4 patches use `files: apPath("lib/syndicator.js")`
- List PATCHES in order: dedup, checkin, draft, unlisted
- [ ] **Verify against local node_modules:**
```bash
node scripts/patch-ap-syndication.mjs
```
Expected: 4 lines of `applied patch-ap-syndicate-*` (or `already applied` if patches were previously applied).
If you see `snippet not found` — the ordering is wrong or the source script's OLD_SNIPPET differs from what's in the file. Re-read the source scripts.
- [ ] **Commit:**
```bash
git add scripts/patch-ap-syndication.mjs
git commit -m "feat: consolidated patch-ap-syndication (dedup, checkin, draft, unlisted)"
```
---
## Task 2: Create `patch-ap-federation-infra.mjs`
**Absorbs:** `patch-ap-federation-bridge-base-url`, `patch-ap-signature-host-header`, `patch-ap-inbox-delivery-debug`, `patch-ap-inbox-publication-url`, `patch-ap-webfinger-before-auth`, and `patch-ap-mastodon-delete-fix` **Change A only** (the `index.js` portion that exposes `broadcastDelete`)
**Target files:** `lib/federation-bridge.js`, `lib/federation-setup.js`, `index.js`
- [ ] **Read all 6 source scripts:**
- `scripts/patch-ap-federation-bridge-base-url.mjs`
- `scripts/patch-ap-signature-host-header.mjs`
- `scripts/patch-ap-inbox-delivery-debug.mjs`
- `scripts/patch-ap-inbox-publication-url.mjs`
- `scripts/patch-ap-webfinger-before-auth.mjs`
- `scripts/patch-ap-mastodon-delete-fix.mjs` — **Change A only** (the snippet targeting `index.js`; Changes B and C target `statuses.js` and go in Task 3)
- [ ] **Note `federation-bridge.js` vs `federation-setup.js`:**
`patch-ap-inbox-delivery-debug` targets two files. Read the script carefully — it patches one snippet in `federation-bridge.js` and one in `federation-setup.js`. Both get their own PATCHES entry with their respective `apPath()` calls.
- [ ] **Note `patch-ap-federation-bridge-base-url` multi-file/multi-snippet:**
This script patches multiple files and/or multiple snippets (e.g., one change in `federation-bridge.js` and one in `index.js`). Each distinct file+snippet combination needs its own PATCHES entry. Do not try to merge them into a single entry — the `applyPatch` helper handles one snippet per call.
- [ ] **ORDERING CONSTRAINT within `federation-bridge.js` entries:**
`patch-ap-inbox-delivery-debug`'s Fix B (the `federation-bridge.js` snippet) anchors on a comment injected by `patch-ap-federation-bridge-base-url` (specifically, the `// ap-base-url patch` comment in the surrounding code). In the PATCHES array, the `federation-bridge-base-url` entry for `federation-bridge.js` must appear **before** the `inbox-delivery-debug` entry for `federation-bridge.js`. Wrong order → delivery-debug snippet not found → silent skip.
- [ ] **Create `scripts/patch-ap-federation-infra.mjs`** using the shared helper.
- Set `SCRIPT = "patch-ap-federation-infra"`
- Patches targeting `index.js` use `files: apPath("index.js")` — note: `index.js` is at the module root, not under `lib/`
- Apply federation-bridge-base-url entries before inbox-delivery-debug entries in the PATCHES array
- [ ] **Verify:**
```bash
node scripts/patch-ap-federation-infra.mjs
```
Expected: one `applied` or `already applied` line per patch (6 patches across 3+ files).
- [ ] **Commit:**
```bash
git add scripts/patch-ap-federation-infra.mjs
git commit -m "feat: consolidated patch-ap-federation-infra (bridge, signatures, inbox, webfinger, delete-fix Change A)"
```
---
## Task 3: Create `patch-ap-mastodon-statuses.mjs`
**Absorbs:** `patch-ap-mastodon-reply-threading`, `patch-ap-mastodon-status-id`, `patch-ap-mastodon-delete-fix` **Changes B and C only**, `patch-ap-status-reply-id` **Change B only**, `patch-ap-interactions-context-state`
**Target file:** `lib/mastodon/routes/statuses.js`
- [ ] **Read 5 source scripts:**
- `scripts/patch-ap-mastodon-reply-threading.mjs`
- `scripts/patch-ap-mastodon-status-id.mjs`
- `scripts/patch-ap-mastodon-delete-fix.mjs` — Changes B and C only (skip the `index.js` Change A already handled in Task 2)
- `scripts/patch-ap-status-reply-id.mjs` — **Change B only**; Change A is upstream-fixed (the script's own upstream-fix guard skips it — include only Change B's OLD/NEW_SNIPPET)
- `scripts/patch-ap-interactions-context-state.mjs`
- [ ] **For `patch-ap-status-reply-id` Change A:** The script checks whether the upstream fix is already present. For the consolidated script, simply omit Change A's snippet entirely — if it was needed, it would already be part of the upstream source.
- [ ] **Create `scripts/patch-ap-mastodon-statuses.mjs`** using the shared helper.
- Set `SCRIPT = "patch-ap-mastodon-statuses"`
- All patches: `files: apPath("lib/mastodon/routes/statuses.js")`
- [ ] **Verify:**
```bash
node scripts/patch-ap-mastodon-statuses.mjs
```
Expected: one line per patch (5 patches).
- [ ] **Commit:**
```bash
git add scripts/patch-ap-mastodon-statuses.mjs
git commit -m "feat: consolidated patch-ap-mastodon-statuses (threading, status-id, delete, reply-id, interactions)"
```
---
## Task 4: Create `patch-ap-mastodon-accounts.mjs`
**Absorbs:** `patch-ap-actor-cache-await`, `patch-ap-resolve-account-timeout-safe`
**Target files:** `lib/mastodon/helpers/resolve-account.js`
**Drops:** `patch-ap-account-lookup-cache-fallback` — upstream already has the fix; script unconditionally skips.
- [ ] **Read 2 source scripts:**
- `scripts/patch-ap-actor-cache-await.mjs`
- `scripts/patch-ap-resolve-account-timeout-safe.mjs`
- [ ] **Create `scripts/patch-ap-mastodon-accounts.mjs`** using the shared helper.
- Set `SCRIPT = "patch-ap-mastodon-accounts"`
- Both patches target `resolve-account.js`: `files: apPath("lib/mastodon/helpers/resolve-account.js")`
- No ordering dependency between the two patches
- [ ] **Verify:**
```bash
node scripts/patch-ap-mastodon-accounts.mjs
```
Expected: 2 lines (applied or already applied).
- [ ] **Commit:**
```bash
git add scripts/patch-ap-mastodon-accounts.mjs
git commit -m "feat: consolidated patch-ap-mastodon-accounts (actor-cache-await, timeout-safe)"
```
---
## Task 5: Create `patch-ap-mastodon-notifications.mjs`
**Absorbs:** `patch-ap-notifications-status-lookup`
**Target file:** `lib/mastodon/routes/notifications.js`
- [ ] **Read source script:** `scripts/patch-ap-notifications-status-lookup.mjs`
- [ ] **Create `scripts/patch-ap-mastodon-notifications.mjs`** using the shared helper.
- Set `SCRIPT = "patch-ap-mastodon-notifications"`
- One patch: `files: apPath("lib/mastodon/routes/notifications.js")`
- [ ] **Verify:**
```bash
node scripts/patch-ap-mastodon-notifications.mjs
```
Expected: 1 line (applied or already applied).
- [ ] **Commit:**
```bash
git add scripts/patch-ap-mastodon-notifications.mjs
git commit -m "feat: consolidated patch-ap-mastodon-notifications (status-lookup)"
```
---
## Task 6: Create `patch-ap-mastodon-misc.mjs`
**Absorbs:** `patch-ap-compose-default-checked`, `patch-ap-og-image`, `patch-ap-repost-announce-fix`, `patch-ap-interactions-send-guard`, `patch-ap-interactions-cleanup-preserve`, `patch-ap-interactions-accounts-uid`, `patch-inbox-ignore-view-activity`, `patch-inbox-skip-view-activity-parse`
**Target files:** various (compose template, og-image handler, interactions handler, inbox handlers)
- [ ] **Read all 8 source scripts** and note their exact target file paths (some will be under `lib/mastodon/routes/`, some under `lib/` root level or `lib/ap/`):
- `scripts/patch-ap-compose-default-checked.mjs`
- `scripts/patch-ap-og-image.mjs`
- `scripts/patch-ap-repost-announce-fix.mjs`
- `scripts/patch-ap-interactions-send-guard.mjs`
- `scripts/patch-ap-interactions-cleanup-preserve.mjs`
- `scripts/patch-ap-interactions-accounts-uid.mjs`
- `scripts/patch-inbox-ignore-view-activity.mjs`
- `scripts/patch-inbox-skip-view-activity-parse.mjs`
- [ ] **Confirm candidate paths per patch** — some of these scripts may target files outside `lib/mastodon/` (e.g., compose template may be in `templates/`, inbox handlers may be in `lib/ap/`). Use exactly the candidate paths from each source script.
- [ ] **Create `scripts/patch-ap-mastodon-misc.mjs`** using the shared helper.
- Set `SCRIPT = "patch-ap-mastodon-misc"`
- Each patch uses its correct `files` array (not all will use `apPath()` — copy candidate arrays verbatim from source scripts)
- No ordering dependencies — all patches target independent snippets or different files
- [ ] **Verify:**
```bash
node scripts/patch-ap-mastodon-misc.mjs
```
Expected: 8 lines (applied or already applied).
- [ ] **Commit:**
```bash
git add scripts/patch-ap-mastodon-misc.mjs
git commit -m "feat: consolidated patch-ap-mastodon-misc (compose, og-image, interactions, inbox)"
```
---
## Task 7: Update `package.json`
Replace the 27 individual AP patch invocations in both `postinstall` and `serve` with the 6 consolidated scripts (+ 2 standalone).
- [ ] **Open `package.json`** and locate both `postinstall` and `serve` strings.
- [ ] **In `postinstall`:** Remove these 27 entries (all `node scripts/patch-ap-*.mjs` and `node scripts/patch-inbox-*.mjs` that were consolidated or are dead):
- `patch-ap-skip-draft-syndication` (dead — drop entirely)
- `patch-ap-og-image`
- `patch-ap-webfinger-before-auth`
- `patch-ap-federation-bridge-base-url`
- `patch-ap-signature-host-header`
- `patch-ap-compose-default-checked`
- `patch-ap-mastodon-reply-threading`
- `patch-ap-mastodon-status-id`
- `patch-ap-interactions-send-guard`
- `patch-ap-interactions-cleanup-preserve`
- `patch-ap-interactions-accounts-uid`
- `patch-ap-interactions-context-state`
- `patch-ap-syndicate-dedup`
- `patch-ap-syndicate-skip-checkin`
- `patch-ap-syndicate-skip-draft`
- `patch-ap-syndicate-skip-unlisted`
- `patch-ap-mastodon-delete-fix`
- `patch-ap-status-reply-id`
- `patch-ap-inbox-publication-url`
- `patch-ap-inbox-delivery-debug`
- `patch-ap-repost-announce-fix`
- `patch-inbox-ignore-view-activity`
- `patch-inbox-skip-view-activity-parse`
- `patch-ap-actor-cache-await`
- `patch-ap-resolve-account-timeout-safe`
- `patch-ap-account-lookup-cache-fallback` (dead — drop entirely)
- `patch-ap-notifications-status-lookup`
The 27 removed entries currently span from `patch-ap-skip-draft-syndication` through `patch-ap-notifications-status-lookup`, with `patch-ap-startup-gate-bypass` interleaved near the end of that block (just before `patch-micropub-category-from-posts`). When deleting the block, keep `patch-ap-startup-gate-bypass` in place — remove everything around it, not the entry itself.
After removal, the `patch-ap-startup-gate-bypass` entry stays at its current position in `postinstall`. Insert the 6 consolidated scripts immediately after it, before `patch-micropub-category-from-posts`:
```
node scripts/patch-ap-startup-gate-bypass.mjs &&
node scripts/patch-ap-oauth-token-expiry-fix.mjs &&
node scripts/patch-ap-federation-infra.mjs &&
node scripts/patch-ap-syndication.mjs &&
node scripts/patch-ap-mastodon-statuses.mjs &&
node scripts/patch-ap-mastodon-accounts.mjs &&
node scripts/patch-ap-mastodon-notifications.mjs &&
node scripts/patch-ap-mastodon-misc.mjs &&
node scripts/patch-micropub-category-from-posts.mjs && ...
```
- [ ] **In `serve`:** Apply the same replacement logic. `patch-ap-startup-gate-bypass` must remain **first** in `serve` (it already is the first entry in the serve script — do not move it).
- [ ] **Verify the JSON is valid:**
```bash
node -e "require('./package.json')" && echo "valid"
```
Expected: `valid`
- [ ] **Dry-run the full postinstall sequence** (without actually running npm install — just run the consolidated scripts manually in order):
```bash
node scripts/patch-ap-federation-infra.mjs && \
node scripts/patch-ap-syndication.mjs && \
node scripts/patch-ap-mastodon-statuses.mjs && \
node scripts/patch-ap-mastodon-accounts.mjs && \
node scripts/patch-ap-mastodon-notifications.mjs && \
node scripts/patch-ap-mastodon-misc.mjs
```
Expected: all report `already applied` (since Tasks 1-6 already applied them). No `snippet not found` warnings.
- [ ] **Commit:**
```bash
git add package.json
git commit -m "refactor: replace 27 individual AP patches with 6 consolidated scripts in postinstall/serve"
```
---
## Task 8: Delete old scripts
- [ ] **Delete the 27 consolidated/dead source scripts:**
```bash
cd "/Users/sven/Library/Mobile Documents/com~apple~CloudDocs/PARA/1. Projects/indiekit-server"
git rm \
scripts/patch-ap-skip-draft-syndication.mjs \
scripts/patch-ap-og-image.mjs \
scripts/patch-ap-webfinger-before-auth.mjs \
scripts/patch-ap-federation-bridge-base-url.mjs \
scripts/patch-ap-signature-host-header.mjs \
scripts/patch-ap-compose-default-checked.mjs \
scripts/patch-ap-mastodon-reply-threading.mjs \
scripts/patch-ap-mastodon-status-id.mjs \
scripts/patch-ap-interactions-send-guard.mjs \
scripts/patch-ap-interactions-cleanup-preserve.mjs \
scripts/patch-ap-interactions-accounts-uid.mjs \
scripts/patch-ap-interactions-context-state.mjs \
scripts/patch-ap-syndicate-dedup.mjs \
scripts/patch-ap-syndicate-skip-checkin.mjs \
scripts/patch-ap-syndicate-skip-draft.mjs \
scripts/patch-ap-syndicate-skip-unlisted.mjs \
scripts/patch-ap-mastodon-delete-fix.mjs \
scripts/patch-ap-status-reply-id.mjs \
scripts/patch-ap-inbox-publication-url.mjs \
scripts/patch-ap-inbox-delivery-debug.mjs \
scripts/patch-ap-repost-announce-fix.mjs \
scripts/patch-inbox-ignore-view-activity.mjs \
scripts/patch-inbox-skip-view-activity-parse.mjs \
scripts/patch-ap-actor-cache-await.mjs \
scripts/patch-ap-resolve-account-timeout-safe.mjs \
scripts/patch-ap-account-lookup-cache-fallback.mjs \
scripts/patch-ap-notifications-status-lookup.mjs
```
- [ ] **Verify no references remain in package.json:**
```bash
grep -c "patch-ap-syndicate-dedup\|patch-ap-account-lookup-cache-fallback\|patch-ap-skip-draft-syndication" package.json
```
Expected: `0`
- [ ] **Commit:**
```bash
git commit -m "refactor: delete 27 consolidated/dead AP patch scripts"
```
---
## Task 9: Final verification
- [ ] **Count remaining AP patch scripts:**
```bash
ls scripts/patch-ap-*.mjs scripts/patch-inbox-*.mjs 2>/dev/null
```
Expected: exactly these files remain:
- `patch-ap-startup-gate-bypass.mjs`
- `patch-ap-oauth-token-expiry-fix.mjs`
- `patch-ap-syndication.mjs`
- `patch-ap-federation-infra.mjs`
- `patch-ap-mastodon-statuses.mjs`
- `patch-ap-mastodon-accounts.mjs`
- `patch-ap-mastodon-notifications.mjs`
- `patch-ap-mastodon-misc.mjs`
- [ ] **Simulate a clean postinstall:**
```bash
npm run postinstall 2>&1 | grep -E "(patch-ap|patch-inbox|snippet not found|ERROR)"
```
If `node_modules` is already patched, all scripts report `already applied` — that's fine and expected. No `snippet not found`, no errors.
- [ ] **Deep verify — confirm MARKERs are present in actual node_modules files** (this catches cases where a patch silently skipped on first run):
```bash
# Syndicator: expect 4 markers
grep -c '\[patch\] ap-syndicate' node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js
# statuses.js: expect at least 4 markers
grep -c '\[patch\] ap-' node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js
# resolve-account.js: expect 2 markers
grep -c '\[patch\] ap-' node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js
# notifications.js: expect 1 marker
grep -c '\[patch\] ap-' node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js
```
If any count is 0 and the patch should have been applied, re-run the consolidated script directly and check for `snippet not found` warnings.
- [ ] **Check package.json postinstall line length** (long lines can cause shell issues on some systems):
```bash
node -e "const p = require('./package.json'); console.log('postinstall:', p.scripts.postinstall.length, 'chars'); console.log('serve:', p.scripts.serve.length, 'chars')"
```
Both should be noticeably shorter than before (was ~3000 chars each).
- [ ] **Tag and push:**
```bash
git log --oneline -8
git push
```
@@ -1,100 +0,0 @@
# Tag Entry Autocomplete — Design Spec
**Date:** 2026-04-09
**Status:** Approved
## Problem
The tag/category input in the Indiekit post compose form is a plain text field with no suggestions. The `q=category` Micropub endpoint returns an empty array because `publication.categories` is not configured — tags only exist in the `posts` MongoDB collection as `properties.category` values.
## Solution
Two patch scripts that wire up autocomplete end-to-end with no new endpoints and no changes to the Indiekit config.
---
## Patch 1 — Backend: `patch-micropub-category-from-posts.mjs`
**Target:** `node_modules/@indiekit/endpoint-micropub/lib/controllers/query.js`
**What it does:**
Inserts a `case "categories":` block before the existing `default:` case in `queryController`. When `q=category` is requested, instead of falling through to `config.categories` (which is an empty array — truthy, so the `default:` branch does not throw, it just returns `{categories:[]}`), this case:
1. Calls `postsCollection.distinct("properties.category")` to get every unique tag used across all published posts. MongoDB `distinct()` on an array field correctly unwraps array values, so `["photography","travel"]` per-post becomes individual distinct entries.
2. Filters out falsy values (`Boolean` filter) and sorts alphabetically.
3. Passes the result through `queryConfig(cats, { filter: filter?.toLowerCase(), limit, offset })` — note: `filter` is lowercased before passing to match `queryConfig`'s internal lowercase comparison, making search case-insensitive end-to-end.
4. Returns `{ categories: [...] }`.
If `postsCollection` is not available, returns `{ categories: [] }` gracefully.
**Example request/response:**
```
GET /micropub?q=category&filter=pho
→ { "categories": ["photography"] }
```
**OLD_SNIPPET anchor:** The patch uses the closing `break;` of the preceding `source:` case plus the `default: {` line as context for the OLD_SNIPPET, since the `default:` block itself does not contain error-throwing logic for the `categories` query (empty array is truthy).
---
## Patch 2 — Frontend: `patch-tag-input-autocomplete.mjs`
**Target:** `node_modules/@indiekit/frontend/components/tag-input/index.js`
**Micropub endpoint URL discovery:**
The patch reads the Micropub endpoint URL using the standard IndieWeb discovery selector:
```js
const micropubHref = document.querySelector('link[rel="micropub"]')?.href ?? "/micropub";
```
This is resolved once per component instantiation. On admin pages Indiekit does not inject the `<link rel="micropub">` element, so the fallback `/micropub` is always used in practice — which is correct for this deployment.
**What it does:**
After the `$tagInputInput` reference is obtained inside `TagInputFieldComponent.connectedCallback()`, appends the following behaviour:
1. Creates a `<datalist id="tag-suggestions-<name>">` element (uid from the input's `name` attribute for stability) and appends it to the component.
2. Sets `$tagInputInput.setAttribute("list", datalistId)` — native browser datalist autocomplete applies to the text field.
3. Adds a debounced (300ms) `input` event listener: if the current value is ≥ 1 char, fetches `GET <micropubHref>?q=category&filter=<value>` and replaces datalist `<option>` elements with `response.categories`.
4. Adds a **`keydown` listener for the Comma key**: after the existing handler adds the tag, this listener fires next (FIFO) and calls `datalist.replaceChildren()` to clear suggestions.
5. Adds a **`blur` listener**: after the existing handler confirms the tag and clears the field, this listener fires next (FIFO) and calls `datalist.replaceChildren()` to clear suggestions.
**Why not rely on `input` event for cleanup:** The existing `keydown`/`blur` handlers clear the field via `$tagInputInput.value = ""` (programmatic assignment). Programmatic `.value` assignment does **not** fire the `input` event per the HTML Living Standard, so cleanup must be wired directly into both confirmation paths.
**Why a `blur` listener is safe here:** Clicking a datalist `<option>` fills the input value and fires `input` — it does not fire `blur` until the user takes a separate action (comma, enter, or clicking elsewhere). By that point our `blur` handler fires after the existing one, which has already processed the value. There is no race with datalist value-fill.
The existing `keydown` (comma) and `blur` (confirm) handlers are not modified — new listeners are added alongside them.
**Interaction flow:**
- User types "ph" → datalist shows "photography", "philosophy"
- User clicks "photography" → text field fills with "photography", `input` fires, datalist refreshes with filtered results
- User presses comma → existing `keydown` confirms tag; our `keydown` clears datalist
- OR user tabs/clicks away → existing `blur` confirms tag; our `blur` clears datalist
---
## Auth
The Micropub `GET /micropub` endpoint is mounted after `indieauth.authenticate()` in `routes.js`. The admin UI is session-authenticated. A same-origin `fetch` from the browser includes the session cookie, so no Bearer token is needed.
---
## Patch script conventions (per CLAUDE.md)
Both scripts follow the standard marker pattern:
- Define `MARKER`, `OLD_SNIPPET`, `NEW_SNIPPET`
- Read file, skip if marker present, warn if old snippet not found, replace and write
- Include both candidate paths: `node_modules/@indiekit/...` and `node_modules/@indiekit/indiekit/node_modules/@indiekit/...`
- Register in both `postinstall` and `serve` scripts (both patches are startup-safe)
- Run each patch script immediately after writing to verify it applies cleanly
---
## Out of scope
- Selecting a suggestion does not auto-confirm the tag as a pill (user confirmed: fill field only, comma/blur to confirm)
- No server-side caching of the distinct query (MongoDB distinct on a small personal-blog collection is fast)
- No UI styling changes to the datalist (native browser rendering)
@@ -1,129 +0,0 @@
# AP Patch Consolidation Design
**Date:** 2026-04-12
**Status:** Approved
## Problem
The `postinstall` and `serve` scripts run ~30 individual AP-related patch scripts. This makes the scripts hard to read, slow to execute (30 `node` process forks), and difficult to reason about when a target file changes upstream.
## Goal
Consolidate 30 AP patch scripts into 6 concern-based scripts. Remove 2 dead patches. Reduce `postinstall`/`serve` lines significantly while preserving all active patches exactly.
## Approach: Concern-Based Consolidation
Each consolidated script reads its target file(s) once, applies all patches in sequence, and writes once. A patch within a consolidated script is still identified by its MARKER comment — idempotency and skip-if-already-applied behavior is preserved per patch, not per file.
## Consolidated Scripts
### 1. `patch-ap-syndication.mjs`
**Targets:** `syndicator.js`, `index.js` (draft guard only)
**Absorbs:** `patch-ap-syndicate-dedup`, `patch-ap-syndicate-skip-checkin`, `patch-ap-syndicate-skip-draft`, `patch-ap-syndicate-skip-unlisted`
**Drops:** `patch-ap-skip-draft-syndication` — dead (upstream format changed; no matching OLD_SNIPPET)
Guard chain order preserved inside `syndicate()`:
1. No-federation guard (existing upstream)
2. Dedup check
3. Checkin skip
4. Draft skip
5. Unlisted skip
**Critical ordering constraint:** Within this script, patches must be applied in the sequence above. `patch-ap-syndicate-skip-draft`'s `OLD_SNIPPET` includes the checkin MARKER output; `patch-ap-syndicate-skip-unlisted`'s `OLD_SNIPPET` includes the draft MARKER output. If the order is wrong, later patches' `OLD_SNIPPET` won't match and will silently skip with a warning. The `PATCHES` array in the implementation must list entries in this exact order: dedup → checkin → draft → unlisted.
**Note on `patch-ap-skip-draft-syndication`:** This patch targets `index.js` and is dead — its `OLD_SNIPPET` no longer matches the current upstream source. The correct enforcement point for draft filtering is `syndicator.js` (absorbed by this script as `patch-ap-syndicate-skip-draft`). The `index.js` guard is redundant and is dropped without replacement.
### 2. `patch-ap-federation-infra.mjs`
**Targets:** `federation-bridge.js`, `federation-setup.js`, `index.js` (webfinger + request auth + delete broadcast portions)
**Absorbs:** `patch-ap-federation-bridge-base-url`, `patch-ap-signature-host-header`, `patch-ap-inbox-delivery-debug`, `patch-ap-inbox-publication-url`, `patch-ap-webfinger-before-auth`, `patch-ap-mastodon-delete-fix` Change A
**Note on delete-fix split:** `patch-ap-mastodon-delete-fix` has two targets. Change A exposes `broadcastDelete` in `mastodonPluginOptions` inside `index.js` — it belongs here alongside the other `index.js` infrastructure patches. Change B/C fix the delete route and broadcast call in `statuses.js` — absorbed by `patch-ap-mastodon-statuses` below.
### 3. `patch-ap-mastodon-statuses.mjs`
**Targets:** `statuses.js`
**Absorbs:** `patch-ap-mastodon-reply-threading`, `patch-ap-mastodon-status-id`, `patch-ap-mastodon-delete-fix` Change B/C, `patch-ap-status-reply-id` (Change B only), `patch-ap-interactions-context-state`
**Notes:**
- `patch-ap-status-reply-id` Change A is upstream-fixed — only Change B (the status ID resolution fix) is included.
- `patch-ap-mastodon-delete-fix` Change A is in `patch-ap-federation-infra` (targets `index.js`); Change B/C stay here.
### 4. `patch-ap-mastodon-accounts.mjs`
**Targets:** `resolve-account.js`, `accounts.js`
**Absorbs:** `patch-ap-actor-cache-await`, `patch-ap-resolve-account-timeout-safe`
**Drops:** `patch-ap-account-lookup-cache-fallback` — dead (upstream fork commit `2b9c31d1c` already adds `const cachedUrl = getActorUrlFromId(id)` in the same location; patch guard skips it, but the script can be removed entirely)
### 5. `patch-ap-mastodon-notifications.mjs`
**Targets:** `notifications.js`
**Absorbs:** `patch-ap-notifications-status-lookup`
**Kept separate** from misc because notifications logic is likely to grow.
### 6. `patch-ap-mastodon-misc.mjs`
**Targets:** `compose.js`, OG image handler, interactions handlers, inbox handlers
**Absorbs:** `patch-ap-compose-default-checked`, `patch-ap-og-image`, `patch-ap-repost-announce-fix`, `patch-ap-interactions-send-guard`, `patch-ap-interactions-cleanup-preserve`, `patch-ap-interactions-accounts-uid`, `patch-inbox-ignore-view-activity`, `patch-inbox-skip-view-activity-parse`
## Patches Not Consolidated
The following AP-adjacent patches target non-AP packages or have standalone complexity that makes consolidation risky:
- `patch-ap-oauth-token-expiry-fix` — targets `mastodon/routes/oauth.js`; standalone auth fix
- `patch-ap-startup-gate-bypass` — targets startup-gate package; runs first in serve, before other patches
Non-AP patches (bluesky, micropub, webmention, etc.) are out of scope.
## Implementation Notes
### Consolidated script structure
Each script follows the existing patch pattern but loops over multiple `(filePath, patchFn)` tuples:
```js
const PATCHES = [
{
filePath: "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/...",
marker: "// [patch] ap-patch-name",
oldSnippet: `...`,
newSnippet: `... // [patch] ap-patch-name`,
},
// ...
];
for (const { filePath, marker, oldSnippet, newSnippet } of PATCHES) {
// standard: skip if marker present, warn if old not found, replace, write
}
```
Both candidate paths (`node_modules/@rmdes/...` and `node_modules/@indiekit/indiekit/node_modules/@rmdes/...`) are included for each patch entry.
### package.json changes
Both `postinstall` and `serve` scripts replace the 30 individual invocations with the 6 consolidated ones. Scripts marked *(serve only)* appear only in the `serve` command, not in `postinstall`.
```
patch-ap-startup-gate-bypass ← both postinstall + serve (preserved); first in serve
patch-ap-oauth-token-expiry-fix ← standalone, auth-sensitive
patch-ap-federation-infra
patch-ap-syndication
patch-ap-mastodon-statuses
patch-ap-mastodon-accounts
patch-ap-mastodon-notifications
patch-ap-mastodon-misc
```
`patch-ap-startup-gate-bypass` is idempotent and harmless in `postinstall` — current behavior (runs in both) is preserved. It must remain the first patch in `serve` to ensure the readiness signal is written before any AP-dependent code runs.
### Verification
After writing consolidated scripts, run each one directly (`node scripts/patch-ap-*.mjs`) against a fresh `node_modules` to confirm all patches apply cleanly before committing.
## Dead Patches to Delete
| Script | Reason |
|--------|--------|
| `scripts/patch-ap-account-lookup-cache-fallback.mjs` | Upstream already has the fix; patch guard skips it unconditionally |
| `scripts/patch-ap-skip-draft-syndication.mjs` | OLD_SNIPPET no longer matches upstream; absorbed by `patch-ap-syndication` without it |
## Result
| Before | After |
|--------|-------|
| 30 AP patch scripts | 6 consolidated + 2 standalone (oauth, startup-gate) |
| 2 dead patches running silently | Removed |
| ~30 `node` forks on serve | ~8 forks |
-5
View File
@@ -1,5 +0,0 @@
# Memory Index
- [project_activitypub.md](project_activitypub.md) - ActivityPub federation architecture and patch chain dependencies
- [project_architecture.md](project_architecture.md) - Server architecture: FreeBSD jails, nginx, internal fetch URLs
- [feedback_patches.md](feedback_patches.md) - Patch management lessons learned
-86
View File
@@ -1,86 +0,0 @@
# Patch Management — Lessons Learned
## Patch Script Pattern
```javascript
const MARKER = "// [patch] my-patch-name";
const OLD_SNIPPET = `exact text to find`;
const NEW_SNIPPET = `replacement text ${MARKER}`;
// Check MARKER first → skip if already applied
// Check OLD_SNIPPET → warn if not found (upstream may have changed)
// Replace and write only if source changed
```
Use `$setOnInsert` for MongoDB upserts in patches that add timeline items — idempotent,
safe to call multiple times (e.g. from both patch and syndicator).
## Target File Candidates
Always include both paths:
```javascript
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/...",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/...",
];
```
## Known Fragile Patterns
- **Exact whitespace matters.** Patch OLD_SNIPPETs must match node_modules source byte-for-byte,
including indentation (spaces not tabs) and exact line endings.
- **Template literals in patches** — escape backticks and `${}` in patch script string literals
using `\`` and `\${}`.
- **`patch-microsub-reader-ap-dispatch`** is in `serve` only (not `postinstall`). Reason unknown
but may relate to timing or the microsub package being rebuilt differently. Check both scripts
when adding new microsub patches.
- **Multi-part patches that chain off each other's MARKERs** — if Change B injects a MARKER that
Change C then targets, deleting Change B leaves Change C silently broken. Either keep the chain
intact, or rewrite the downstream change to match the upstream code directly (without the marker).
- **Delta-only patches that depend on a deleted base patch** — `patch-micropub-gitea-dispatch-conditional`
was written as a delta on a base patch that was later deleted. When a base patch is removed from
`package.json`, all delta patches that reference its output must be rewritten as standalone patches.
## When Upstream Fixes a Patched Bug
When an upstream upgrade fixes something your patch was correcting:
1. Add an "upstream fix indicator" string that appears in the newly-fixed upstream code.
2. In `applyPatch`, check for this indicator before trying to apply the OLD_SNIPPET.
3. Log `"already fixed upstream"` (not a warning) and skip gracefully.
4. Update the patch comment to note the date the upstream fix landed.
This avoids noisy warnings on every `postinstall` while keeping the patch script as a fallback
for older upstream versions.
## AP Threading — Two Compose Paths
Replies can come from two different UIs. Each has different syndication logic:
| Path | Syndication checkbox pre-checked by |
|------|-------------------------------------|
| AP reader (`/activitypub/admin/reader/compose`) | `target.defaultChecked` set in `composeController()` |
| Microsub reader (`/microsub/admin/reader/compose`) | `target.checked` from Micropub `q=config` response |
| Mastodon client API (`POST /api/v1/statuses`) | `mp-syndicate-to` hardcoded to `publicationUrl` (always AP) |
The AP reader compose form uses `target.defaultChecked` (NOT `target.checked`) in its template.
If `composeController()` doesn't set `defaultChecked` correctly, the AP checkbox is unchecked
even though `target.checked = true` from the Micropub config.
## ap_timeline Insertion Timing
Own posts reach `ap_timeline` via TWO paths:
1. **Mastodon API** (`POST /api/v1/statuses`): Now inserts immediately after `postContent.create()`
via `patch-ap-mastodon-reply-threading`. The syndicator's later upsert is a no-op (`$setOnInsert`).
2. **Micropub + syndication webhook**: AP syndicator inserts after Eleventy build completes (30120 s).
Always ensure new post creation paths insert to `ap_timeline` immediately if the post may be
replied to again before the build webhook fires.
## Post Type Discovery Dependency
`getPostType(postTypes, properties)` uses `Map.has(key)` — only KEY presence matters, not value.
- `"in-reply-to"` present (any value, even empty) → `"reply"`
- If `in-reply-to` is absent → falls through to `"note"`
If `in-reply-to` is silently lost (null inReplyTo in Mastodon API, unchecked form field, etc.),
the post silently becomes a note with no error. Check this first when debugging "wrong post type" issues.
-269
View File
@@ -1,269 +0,0 @@
# ActivityPub Federation — Architecture & Patch Chain
## Key Files
| File | Role |
|------|------|
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js` | AP reader compose form: GET builds the form with `syndicationTargets`, POST submits to Micropub |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js` | Mastodon-compatible API: `POST /api/v1/statuses` creates posts via Micropub pipeline |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js` | AP syndicator: federates posts to followers, adds own posts to `ap_timeline` |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js` | Converts JF2 properties to ActivityPub Note/Create activity |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/storage/timeline.js` | `addTimelineItem()` — atomic upsert (`$setOnInsert`) to `ap_timeline` |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js` | Serializes `ap_timeline` documents to Mastodon Status JSON |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/pagination.js` | `encodeCursor(date)` / `decodeCursor(id)` — ms-since-epoch cursors (for timeline pagination, NOT status IDs) |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-reply-ids.js` | Batch-resolves `inReplyTo` URLs to MongoDB ObjectId strings via `ap_timeline` lookup |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/id-mapping.js` | Maps remote actor URLs to stable local account IDs |
| `node_modules/@indiekit/endpoint-micropub/lib/post-type-discovery.js` | `getPostType()` — returns "reply" if `in-reply-to` key present, else "note" |
| `node_modules/@indiekit/endpoint-micropub/lib/post-data.js` | Micropub create pipeline: normalise → getPostType → save |
| `node_modules/@rmdes/indiekit-endpoint-microsub/lib/controllers/reader.js` | Microsub reader compose: auto-selects syndication targets via `detectProtocol()` |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/views/activitypub-compose.njk` | AP reader compose template: uses `target.defaultChecked` for checkbox state |
| `node_modules/@rmdes/indiekit-endpoint-activitypub/views/partials/ap-item-card.njk` | Timeline card: reply button passes `item.url` as `replyTo` query param |
## Data Flow: Reply via AP Reader UI
```
User clicks reply on timeline item
→ /activitypub/admin/reader/compose?replyTo=<url>
→ composeController() fetches syndicationTargets from Micropub q=config
→ sets target.defaultChecked = target.checked === true [patch: ap-compose-default-checked]
→ renders activitypub-compose.njk with hidden in-reply-to field
→ user submits form
→ submitComposeController() POSTs to Micropub with in-reply-to
→ Micropub: formEncodedToJf2 → normaliseProperties → getPostType("reply") → save to /replies/{slug}/
→ syndication webhook fires → AP syndicator.syndicate(properties)
→ jf2ToAS2Activity creates Note with inReplyTo
→ delivered to followers + original author's inbox
```
## Data Flow: Reply via Mastodon Client API (Phanpy/Elk)
```
Client sends POST /api/v1/statuses { status, in_reply_to_id }
→ findTimelineItemById(ap_timeline, in_reply_to_id)
→ collection.findOne({ _id: new ObjectId(id) }) ← ObjectId lookup since 2026-04-09 upgrade
→ inReplyTo = replyItem.uid || replyItem.url
→ jf2["in-reply-to"] = inReplyTo (if resolved)
→ jf2["mp-syndicate-to"] = [publicationUrl] (always set — AP-only)
→ postData.create → postContent.create
→ _tlItem = addTimelineItem(...) immediately [patch: ap-mastodon-reply-threading]
→ returns status with id: _tlItem._id.toString() [patch: ap-mastodon-status-id]
→ build webhook → syndicator → AP delivery
```
## Own Post in ap_timeline
The AP syndicator stores outbound posts with:
- `uid = url = properties.url` (blog URL, e.g. `https://blog.giersig.eu/replies/slug/`)
- `published = properties.published` (ISO 8601 with TZ offset, e.g. `"2026-03-21T16:33:50+01:00"`)
- `inReplyTo = properties["in-reply-to"]` (original Mastodon URL or own blog URL)
- `_id` = MongoDB ObjectId (auto-generated by `addTimelineItem`)
**Status ID format (post 2026-04-09 upgrade):** MongoDB ObjectId string.
`findTimelineItemById` does `collection.findOne({ _id: new ObjectId(id) })`.
The `POST /api/v1/statuses` response now returns `_tlItem._id.toString()` as the status ID
(patched by `patch-ap-mastodon-status-id`) so subsequent `in_reply_to_id` lookups resolve correctly.
`in_reply_to_id` in the status serializer is resolved via `resolveReplyIds()`, which batch-looks up
`ap_timeline` items by `uid`/`url` and returns `_id.toString()` — upstream now handles this properly.
## Syndication Target Config
The AP syndicator's `info` object:
```javascript
{
checked: true, // from indiekit.config.mjs: checked: true
name: `@${handle}@${hostname}`, // e.g. "@svemagie@blog.giersig.eu"
uid: publicationUrl, // e.g. "https://blog.giersig.eu/"
service: { name: "ActivityPub (Fediverse)", ... }
}
```
The Micropub `q=config` endpoint returns this with `checked: true`.
Both compose forms read this value:
- **Microsub reader**: uses `target.checked` directly (always pre-checked if `checked: true` in config)
- **AP reader**: uses `target.defaultChecked` set by `composeController()` — was broken, now fixed
## Patches Applied (AP threading)
### `patch-ap-compose-default-checked`
**File:** `lib/controllers/compose.js`
**Problem:** `target.defaultChecked` was hardcoded to `name === "@rick@rmendes.net"` — never matches.
**Fix:** `target.defaultChecked = target.checked === true`
**Effect:** AP syndication checkbox is pre-checked in the AP reader compose form, matching
the `checked: true` config — replies via the AP reader UI are now federated.
### `patch-ap-mastodon-reply-threading`
**File:** `lib/mastodon/routes/statuses.js`
**Problem:** After `POST /api/v1/statuses`, own post was NOT inserted into `ap_timeline` until
the Eleventy build webhook fired (30120 s later). Follow-up replies during that window
would fail `findTimelineItemById``inReplyTo = null` → no `in-reply-to` in JF2 →
`getPostType` returned `"note"` → reply saved at `/notes/` with no `inReplyTo` in the AP activity.
**Fix:** Eagerly insert a provisional timeline item via `addTimelineItem()` immediately after
`postContent.create()`. Uses `$setOnInsert` (idempotent); syndicator's later upsert is a no-op.
**Effect:** `in_reply_to_id` can be resolved immediately → correct `"reply"` post type → proper
`inReplyTo` in the AP Note → thread displays correctly on Mastodon.
## Post Type Discovery
`getPostType(postTypes, properties)` in `post-type-discovery.js`:
- Checks `propertiesMap.has("in-reply-to")``"reply"`
- Checks `propertiesMap.has("like-of")``"like"`
- Checks `propertiesMap.has("repost-of")``"repost"`
- Falls through to `"note"` if none match
The `"reply"` post type in `indiekit.config.mjs` has no `discovery` field — standard PTD spec applies.
## Inbound AP Activity Pipeline
Activities from remote servers follow this path:
```
Remote server → nginx → Express (body buffered in createFedifyMiddleware)
→ Fedify signature check (uses req._rawBody for digest)
→ Fedify Redis message queue (if Redis configured)
→ Fedify queue worker → inbox listener (inbox-listeners.js)
→ enqueueActivity() → ap_inbox_queue (MongoDB)
→ startInboxProcessor() (1s poll) → routeToHandler()
→ handleLike / handleAnnounce / handleCreate
→ addNotification() → ap_notifications
```
**Critical: `collections._publicationUrl`** is set in `index.js` (`_publicationUrl: this._publicationUrl`)
AND by `patch-ap-inbox-publication-url` in `federation-setup.js`. Both set `"https://blog.giersig.eu/"`.
Notification conditions gate on `pubUrl && objectId.startsWith(pubUrl)`:
- `handleLike`: only notifies for likes of our own content
- `handleAnnounce` PATH 1: only notifies for boosts of our content
- `handleCreate`: only notifies for replies to our posts (`inReplyTo.startsWith(pubUrl)`)
**Body buffering** (`createFedifyMiddleware`): `application/activity+json` bodies are buffered
into `req._rawBody` before `express.json()` (which only handles `application/json`) touches them.
`fromExpressRequest` passes `req._rawBody` verbatim to the Fedify `Request` object so the
HTTP Signature Digest check passes.
**Fedify inbox log suppression**: `["fedify","federation","inbox"]` was hardcoded to `"fatal"`
(`patch-ap-inbox-delivery-debug` fixes this to `"error"` so real failures are visible).
**Diagnosing inbox delivery issues:**
- Set `AP_DEBUG=1` → logs `[AP-inbox] POST /activitypub/users/svemagie/inbox ct=... body=...B`
BEFORE Fedify's signature check. If this doesn't appear, activities aren't reaching our server.
- With inbox log level now `"error"`: signature failures show as Fedify error logs.
- Queue processing failures: `[inbox-queue] Failed processing ...` — always logged.
### `patch-ap-signature-host-header` *(2026-04-01)*
**File:** `lib/controllers/federation-bridge.js``fromExpressRequest()`
**Problem:** `patch-ap-federation-bridge-base-url` fixed Fedify URL routing to use the canonical
`publicationUrl`, but left the `host` header in the copied Headers object untouched. nginx forwards
an internal host (e.g. `10.100.0.20`) which Fedify reads from `request.headers.get("host")` when
reconstructing the signed-string for Cavage HTTP Signatures. Signed-string mismatch → every inbox
POST returns 401 → remote servers exhaust retries and stop delivering.
**Fix:** After the header-copy loop in `fromExpressRequest()`, override `"host"` with
`new URL(publicationUrl).host` (`"blog.giersig.eu"`) when `publicationUrl` is provided.
**Effect:** HTTP Signature verification now succeeds for all inbound AP activities.
### `patch-ap-mastodon-status-id` *(updated 2026-04-09)*
**File:** `lib/mastodon/routes/statuses.js`
**Original problem (2026-04-01):** `POST /api/v1/statuses` returned `id: String(Date.now())`.
With timestamp-cursor IDs, this caused ±1 s range misses when the client immediately replied.
**Upstream change (2026-04-09):** `findTimelineItemById` switched to `new ObjectId(id)` lookup.
`String(Date.now())` is not a valid ObjectId → lookup always returns null → `inReplyTo = null`.
**Current fix:** Capture `addTimelineItem()` return value as `_tlItem`; use `_tlItem._id.toString()`
as the response ID (falls back to `String(Date.now())`). Response ID is now a valid ObjectId that
`findTimelineItemById` can resolve.
### `patch-ap-interactions-send-guard` *(2026-04-01)*
**File:** `lib/mastodon/helpers/interactions.js`
**Problem:** `likePost` and `boostPost` call `ctx.sendActivity(...)` without try/catch. Any Fedify
or Redis error propagates → 500 response → the `ap_interactions` DB write never runs → interaction
not recorded locally.
**Fix:** Wrap both `sendActivity` calls in try/catch so delivery failures are non-fatal. Interaction
still recorded in `ap_interactions`; client sees correct UI state.
### `patch-ap-syndicate-dedup` *(2026-04-01)*
**File:** `lib/syndicator.js``syndicate()`
**Problem:** The CI webhook calls `/syndicate?source_url=X&force=true` after every Eleventy build.
When `syndicateToTargets()` saves the syndication URL it commits to Gitea → triggers another build
→ second CI call also hits the syndicate endpoint → duplicate `Create(Note)` activity sent.
Root cause: the AP syndicator UID (`publicationUrl`) shares the same origin as the syndication URL
(`properties.url`), so `force` mode re-selects it.
**Fix:** At the start of `syndicate()`, query `ap_activities` for an existing outbound
Create/Announce/Update for `properties.url`. If found, return the existing URL without re-federating.
### `patch-ap-mastodon-delete-fix` *(updated 2026-04-09)*
**File:** `lib/mastodon/routes/statuses.js` (delete route) + `index.js`
**Bug 1 (ReferenceError) — now fixed upstream:** Delete route used `objectId` (undefined) instead
of `item._id`. Upstream (2026-04-09) fixed this natively; patch no longer applies Change B.
**Bug 2 (no AP broadcast) — still patched:** Route calls `postContent.delete()` directly, bypassing
the syndicator framework → no `Delete(Note)` activity sent to followers.
**Fix:** (a) Add `broadcastDelete: (url) => pluginRef.broadcastDelete(url)` to `mastodonPluginOptions`
in `index.js` (Change A — still applied). (b) Call `broadcastDelete(postUrl)` after the
`deleteOne` in the delete route (Change C — matches upstream code directly, no marker chain).
### `patch-micropub-delete-propagation` + `patch-bluesky-syndicator-delete` *(2026-04-01)*
**Files:** `node_modules/@indiekit/endpoint-micropub/lib/action.js` + Bluesky syndicator
**Problem:** Micropub `action=delete` only deleted the post from the content store. AP and Bluesky
syndications persisted.
**Fix:** After `postContent.delete()`, iterate `publication.syndicationTargets` and call
`syndicator.delete(url, syndication)` fire-and-forget for any syndicator exposing `.delete()`.
Bluesky syndicator extended with `deletePost(bskyUrl)` (via `com.atproto.repo.deleteRecord`) and
`delete(url, syndication)` that resolves the bsky.app URL from the preserved `_deletedProperties`.
### `patch-ap-inbox-publication-url` *(via 63bc41ebb, 2026-04-01)*
**File:** `lib/controllers/federation-setup.js`
**Problem:** `collections._publicationUrl` was never set in `federation-setup.js`, so every
`pubUrl && objectId.startsWith(pubUrl)` guard in `handleCreate`/`handleAnnounce` always evaluated
to `undefined` → no reply notifications, no boost notifications for own content, replies from
non-followers not stored in `ap_timeline`.
**Fix:** Set `collections._publicationUrl = publicationUrl` before `registerInboxListeners()`.
Also added else-if branch in `handleCreate` to store replies to own posts in `ap_timeline` even
when sender is not in `ap_following`.
### `patch-ap-interactions-cleanup-preserve` *(2026-04-10)*
**File:** `lib/timeline-cleanup.js``cleanupTimeline()`
**Problem:** The daily cleanup deleted remote posts from `ap_timeline` (beyond `retentionLimit`) AND
deleted their corresponding `ap_interactions` entries. Any post the user had liked, bookmarked, or
boosted would silently disappear from `GET /api/v1/favourites` and `GET /api/v1/bookmarks` after the
next cleanup run. This was intermittent — only triggered once per day.
**Fix:** Before deleting, call `ap_interactions.distinct("objectUrl")` to get all URLs the user has
interacted with. Filter those UIDs out of `toDelete` so they are never removed from `ap_timeline`
(and their `ap_interactions` entries remain intact).
### `patch-ap-interactions-accounts-uid` *(2026-04-10)*
**File:** `lib/mastodon/routes/accounts.js` → account statuses route
**Problem:** When loading interaction state for `GET /api/v1/accounts/:id/statuses`, the code built
`lookupUrls` from both `item.uid` and `item.url`, found matching interactions, but then added
`ix.objectUrl` directly to `favouritedIds`/`rebloggedIds`/`bookmarkedIds`. `serializeStatus` checks
`favouritedIds.has(item.uid)` — so when `ix.objectUrl === item.url` and `item.url !== item.uid` (common
for remote posts), the interaction state showed wrong (not-liked even though liked).
**Fix:** Build a `urlToUid` map before the lookup and resolve `ix.objectUrl` to the canonical `item.uid`
before adding to the Sets (same approach as `loadInteractionState` in `timelines.js`).
### `patch-ap-interactions-context-state` *(2026-04-10)*
**File:** `lib/mastodon/routes/statuses.js``GET /api/v1/statuses/:id/context`
**Problem:** Thread ancestors and descendants were serialized with `emptyInteractions` (all empty Sets).
Any post the user had liked or bookmarked showed as not-liked/not-bookmarked when viewing thread context.
**Fix:** Replace `emptyInteractions` with a batch `ap_interactions` lookup for all context items
(ancestors + descendants), using a `urlToUid` map to resolve to canonical UIDs. One MongoDB query
for the entire thread instead of N+1 calls.
### `patch-ap-status-reply-id` *(updated 2026-04-09)*
**Files:** `lib/mastodon/entities/status.js` + `lib/mastodon/routes/statuses.js`
**Original problem (2026-04-01):** `in_reply_to_id` was tautological `item.inReplyTo ? null : null`.
**Change A — now fixed upstream (2026-04-09):** `status.js` now uses `replyIdMap?.get(item.inReplyTo)`
(resolved via `resolveReplyIds()` batch lookup). Patch detects the upstream fix and skips Change A silently.
**Change B — still applied:** `statuses.js` POST handler pre-insert stores
`inReplyToId: inReplyToId || null` so the item is available for `resolveReplyIds` lookups.
**Effect:** Own replies are threaded correctly in Phanpy/Elk; inbound AP replies use `resolveReplyIds`.
---
## detectProtocol() in Microsub Reader
`detectProtocol(url)` in `reader.js` classifies URLs for syndication auto-selection:
- `"atmosphere"` — bsky.app / bluesky
- `"fediverse"` — mastodon., mstdn., fosstodon., troet., social., misskey., pixelfed., hachyderm., infosec.exchange, chaos.social (extended by `patch-microsub-reader-ap-dispatch`)
- `"web"` — everything else, including own blog URLs
Own blog URLs return `"web"`, so auto-selection doesn't trigger for reply-to-own-reply in the
microsub reader. This is harmless because `checked: true` in the config already pre-checks the
AP target in the microsub reader's `target.checked` field.
-56
View File
@@ -1,56 +0,0 @@
# Server Architecture
## Infrastructure
- **FreeBSD jails** — Indiekit runs in an isolated jail
- **nginx** — reverse proxy; must forward `Host: blog.giersig.eu` and `X-Forwarded-Proto: https`
for Fedify to construct correct canonical URLs (see `patch-ap-federation-bridge-base-url`)
- **MongoDB** — `10.100.0.20:27017`, database `indiekit`, auth source `admin`
- **Redis** — optional; URL via `REDIS_URL` env var; used for AP activity queue
## Publication URLs
- `publicationBaseUrl` = `https://blog.giersig.eu` (from `PUBLICATION_URL` env or hardcoded default)
- `applicationBaseUrl` = same (from `INDIEKIT_URL` env)
- GitHub repo: `svemagie/blog`, branch `main`
## Internal Fetch
Several patches rewrite outbound HTTP fetches to use internal jail addresses instead of
going through the public internet / nginx:
- `patch-micropub-fetch-internal-url` — Micropub post creation fetches
- `patch-bluesky-syndicator-internal-url` — Bluesky syndicator
- `_toInternalUrl()` helper in microsub/activitypub controllers
## Collections (MongoDB)
| Collection | Contents |
|------------|----------|
| `posts` | Micropub post data (path + properties) |
| `ap_timeline` | Incoming + outgoing AP posts; key: `uid` |
| `ap_notifications` | Mentions, replies, likes, boosts received |
| `ap_followers` | Follower actor URLs |
| `ap_following` | Following actor URLs |
| `ap_activities` | Activity log (outbound + inbound) |
| `ap_profile` | Own actor profile (name, icon, url) |
| `ap_interactions` | Likes and boosts performed by own account |
## ActivityPub Actor
- Handle: `activityPubHandle` from `AP_HANDLE` env → `GITHUB_USERNAME` (`svemagie`) → hostname prefix
- Full handle: `@svemagie@blog.giersig.eu`
- Actor URL: `https://blog.giersig.eu/activitypub/actor`
- AP objects served at: `https://blog.giersig.eu/activitypub/objects/note/{+id}`
- Own reply posts: `/activitypub/objects/note/replies/{slug}`
## Patch Infrastructure
Patches live in `scripts/patch-*.mjs`. Each script:
1. Checks if already applied (MARKER string)
2. Looks for OLD_SNIPPET in node_modules target file
3. Replaces with NEW_SNIPPET if found
4. Reports result to stdout
Both `postinstall` and `serve` scripts in `package.json` run all patches in order.
Some patches (e.g. `patch-microsub-reader-ap-dispatch`) only appear in `serve`, not `postinstall`.
New AP patches are appended at the end of the AP patch chain (after `patch-ap-federation-bridge-base-url`).