chore: remove internal planning docs and memory from git tracking
Deploy Indiekit Server / deploy (push) Successful in 1m27s
Deploy Indiekit Server / deploy (push) Successful in 1m27s
This commit is contained in:
@@ -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 2–3 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 |
|
||||
@@ -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
|
||||
@@ -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 (30–120 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.
|
||||
@@ -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 (30–120 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.
|
||||
@@ -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`).
|
||||
Reference in New Issue
Block a user