From 4827a98614a23787d3613e7f51209f512d0be048 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 20 Feb 2026 22:57:41 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Fedify=20feature=20completeness=20?= =?UTF-8?q?=E2=80=94=20collections,=20dispatchers,=20delivery=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement all missing Fedify features for full ActivityPub compliance: - Liked, Featured, Featured Tags collection dispatchers with admin UIs - Object dispatcher for Note/Article dereferencing at AP URIs - Instance actor (Application type) for domain-level federation - Handle aliases (.mapAlias) for profile URL and /@handle resolution - Configurable actor type (Person/Service/Organization/Group) - Dynamic NodeInfo version from @indiekit/indiekit package.json - Context data propagation (handle + publication URL) - ParallelMessageQueue wrapping RedisMessageQueue (5 workers) - Collection sync (FEP-8fcf) and ordering keys on sendActivity - Permanent failure handler stub (deferred to Fedify 2.0) - Profile attachments (PropertyValue) and alsoKnownAs support - Strip invalid "type":"as:Endpoints" from actor JSON (Fedify #576) - Fix .mapAlias() return type ({identifier} not bare string) - Remove .authorize() predicate (causes 401 loops without auth doc loader) - Narrow content negotiation router to /nodeinfo/ only 22/22 compliance tests pass (Grade A+). Version 1.0.26. --- docs/plans/2026-02-20-federation-hardening.md | 737 ++++++++++++++++++ .../2026-02-20-fedify-feature-completeness.md | 650 +++++++++++++++ docs/plans/fedify-migration.md | 540 +++++++++++++ index.js | 54 +- lib/batch-refollow.js | 6 +- lib/controllers/featured-tags.js | 71 ++ lib/controllers/featured.js | 117 +++ lib/controllers/profile.js | 12 +- lib/federation-bridge.js | 30 +- lib/federation-setup.js | 279 ++++++- lib/inbox-listeners.js | 1 + locales/en.json | 4 + package-lock.json | 8 +- package.json | 6 +- views/activitypub-featured-tags.njk | 43 + views/activitypub-featured.njk | 52 ++ views/activitypub-profile.njk | 12 + 17 files changed, 2595 insertions(+), 27 deletions(-) create mode 100644 docs/plans/2026-02-20-federation-hardening.md create mode 100644 docs/plans/2026-02-20-fedify-feature-completeness.md create mode 100644 docs/plans/fedify-migration.md create mode 100644 lib/controllers/featured-tags.js create mode 100644 lib/controllers/featured.js create mode 100644 views/activitypub-featured-tags.njk create mode 100644 views/activitypub-featured.njk diff --git a/docs/plans/2026-02-20-federation-hardening.md b/docs/plans/2026-02-20-federation-hardening.md new file mode 100644 index 0000000..193e9cf --- /dev/null +++ b/docs/plans/2026-02-20-federation-hardening.md @@ -0,0 +1,737 @@ +# Federation Hardening Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix all spec compliance issues found during the Fedify docs audit — persistent Ed25519 keys, proper `assertionMethods`, Redis message queue, Reject listener, database indexes, and storeRawActivities wiring. + +**Architecture:** Changes are isolated to the plugin's `lib/` layer (federation-setup.js, inbox-listeners.js, activity-log.js) plus package.json for the new `@fedify/redis` dependency. The plugin accepts an optional `redisUrl` from Indiekit config; when present, it uses `RedisMessageQueue` instead of `InProcessMessageQueue`. Ed25519 keys are generated once and persisted to `ap_keys` as JWK. Cloudron deployment config is updated to pass through the Redis URL. + +**Tech Stack:** `@fedify/fedify` ^1.10.0, `@fedify/redis` (new), `ioredis` (new), MongoDB (existing) + +--- + +### Task 1: Persist Ed25519 key pair to database + +**Files:** +- Modify: `lib/federation-setup.js` (lines 139-167, setKeyPairsDispatcher) + +**Context:** Currently `generateCryptoKeyPair("Ed25519")` is called on every request, producing a new key pair each time. Remote servers fetching the actor to verify an Object Integrity Proof get a different public key than the one used to sign — causing silent verification failures. The Fedify docs say: "generate key pairs for each actor when the actor is created" and store them persistently using `exportJwk()`/`importJwk()`. + +**Step 1: Add `exportJwk` and `importJwk` to imports** + +At the top of `federation-setup.js`, add to the existing import: + +```javascript +import { + Endpoints, + Image, + InProcessMessageQueue, + Person, + PropertyValue, + createFederation, + exportJwk, // ADD + generateCryptoKeyPair, + importJwk, // ADD + importSpki, +} from "@fedify/fedify"; +``` + +**Step 2: Rewrite `setKeyPairsDispatcher` to persist Ed25519** + +Replace the entire `.setKeyPairsDispatcher(async (ctx, identifier) => { ... })` block (lines 139-167) with: + +```javascript + .setKeyPairsDispatcher(async (ctx, identifier) => { + if (identifier !== handle) return []; + + const keyPairs = []; + + // --- Legacy RSA key pair (HTTP Signatures) --- + const legacyKey = await collections.ap_keys.findOne({ type: "rsa" }); + // Fall back to old schema (no type field) for backward compat + const rsaDoc = legacyKey || await collections.ap_keys.findOne({ + publicKeyPem: { $exists: true }, + }); + + if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) { + try { + const publicKey = await importSpki(rsaDoc.publicKeyPem); + const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem); + keyPairs.push({ publicKey, privateKey }); + } catch { + console.warn("[ActivityPub] Could not import legacy RSA keys"); + } + } + + // --- Ed25519 key pair (Object Integrity Proofs) --- + // Load from DB or generate + persist on first use + let ed25519Doc = await collections.ap_keys.findOne({ type: "ed25519" }); + + if (ed25519Doc?.publicKeyJwk && ed25519Doc?.privateKeyJwk) { + try { + const publicKey = await importJwk(ed25519Doc.publicKeyJwk, "public"); + const privateKey = await importJwk(ed25519Doc.privateKeyJwk, "private"); + keyPairs.push({ publicKey, privateKey }); + } catch (error) { + console.warn( + "[ActivityPub] Could not import Ed25519 keys, regenerating:", + error.message, + ); + ed25519Doc = null; // Force regeneration below + } + } + + if (!ed25519Doc) { + try { + const ed25519 = await generateCryptoKeyPair("Ed25519"); + await collections.ap_keys.insertOne({ + type: "ed25519", + publicKeyJwk: await exportJwk(ed25519.publicKey), + privateKeyJwk: await exportJwk(ed25519.privateKey), + createdAt: new Date().toISOString(), + }); + keyPairs.push(ed25519); + console.info("[ActivityPub] Generated and persisted Ed25519 key pair"); + } catch (error) { + console.warn( + "[ActivityPub] Could not generate Ed25519 key pair:", + error.message, + ); + } + } + + return keyPairs; + }); +``` + +**Step 3: Verify** + +Run: `fedify lookup https://rmendes.net/activitypub/users/rick` + +Expected: Actor still resolves with `publicKey` and `assertionMethod` visible. Restart the app and re-run — the same key should be returned (verify by comparing the key IDs between requests). + +**Step 4: Commit** + +``` +feat(keys): persist Ed25519 key pair to ap_keys collection + +Previously generated a new Ed25519 key pair on every request, +causing Object Integrity Proof verification failures on remote +servers. Now generates once and stores as JWK in MongoDB. +``` + +--- + +### Task 2: Fix `assertionMethods` (plural) on actor + +**Files:** +- Modify: `lib/federation-setup.js` (lines 113-117, actor dispatcher) + +**Context:** The actor currently sets `assertionMethod` (singular) with only the first key's multikey. The Fedify docs specify `assertionMethods` (plural array) containing ALL multikey instances — typically one per key pair (RSA + Ed25519). + +**Step 1: Replace singular with plural** + +In the actor dispatcher, find: + +```javascript +if (keyPairs.length > 0) { + personOptions.publicKey = keyPairs[0].cryptographicKey; + personOptions.assertionMethod = keyPairs[0].multikey; +} +``` + +Replace with: + +```javascript +if (keyPairs.length > 0) { + personOptions.publicKey = keyPairs[0].cryptographicKey; + personOptions.assertionMethods = keyPairs.map((k) => k.multikey); +} +``` + +**Step 2: Verify** + +Run: `fedify lookup https://rmendes.net/activitypub/users/rick` + +Expected: Actor output shows `assertionMethods` (plural) with entries for both RSA and Ed25519 keys. + +**Step 3: Commit** + +``` +fix(actor): use assertionMethods (plural) per Fedify spec + +Exposes all key pair multikeys (RSA + Ed25519) instead of only +the first. Required for proper Object Integrity Proof verification. +``` + +--- + +### Task 3: Add `@fedify/redis` dependency and `redisUrl` config option + +**Files:** +- Modify: `package.json` +- Modify: `index.js` (constructor, init method) + +**Context:** The plugin needs to accept an optional `redisUrl` from Indiekit config and pass it through to federation setup. When not provided, behavior remains unchanged (InProcessMessageQueue). + +**Step 1: Add dependencies** + +```bash +cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub +npm install @fedify/redis ioredis +``` + +This adds both packages to `package.json` `dependencies`. + +**Step 2: Add `redisUrl` to defaults in `index.js`** + +Find the `defaults` object (line 32): + +```javascript +const defaults = { + mountPath: "/activitypub", + actor: { + handle: "rick", + name: "", + summary: "", + icon: "", + }, + checked: true, + alsoKnownAs: "", + activityRetentionDays: 90, + storeRawActivities: false, +}; +``` + +Add `redisUrl`: + +```javascript +const defaults = { + mountPath: "/activitypub", + actor: { + handle: "rick", + name: "", + summary: "", + icon: "", + }, + checked: true, + alsoKnownAs: "", + activityRetentionDays: 90, + storeRawActivities: false, + redisUrl: "", +}; +``` + +**Step 3: Pass `redisUrl` to `setupFederation` in `init()`** + +In the `init(Indiekit)` method, find the `setupFederation` call (around line 626): + +```javascript +const { federation } = setupFederation({ + collections: this._collections, + mountPath: this.options.mountPath, + handle: this.options.actor.handle, + storeRawActivities: this.options.storeRawActivities, +}); +``` + +Add `redisUrl`: + +```javascript +const { federation } = setupFederation({ + collections: this._collections, + mountPath: this.options.mountPath, + handle: this.options.actor.handle, + storeRawActivities: this.options.storeRawActivities, + redisUrl: this.options.redisUrl, +}); +``` + +**Step 4: Commit** + +``` +feat(redis): add @fedify/redis dependency and redisUrl config option + +Plugin now accepts optional redisUrl from Indiekit config. +Plumbing only — actual Redis usage is wired in the next commit. +``` + +--- + +### Task 4: Use `RedisMessageQueue` when `redisUrl` is provided + +**Files:** +- Modify: `lib/federation-setup.js` (imports and `createFederation` call) + +**Context:** Replace `InProcessMessageQueue` with `RedisMessageQueue` when Redis is available. The `RedisMessageQueue` constructor takes a factory function `() => new Redis(url)` so Fedify can create connections as needed. + +**Step 1: Update imports and federation creation** + +At the top of `federation-setup.js`, keep `InProcessMessageQueue` in the import (used as fallback) and add a conditional import approach. Replace the `createFederation` block: + +Find: + +```javascript +const federation = createFederation({ + kv: new MongoKvStore(collections.ap_kv), + queue: new InProcessMessageQueue(), +}); +``` + +Replace with: + +```javascript +let queue; +if (redisUrl) { + const { RedisMessageQueue } = await import("@fedify/redis"); + const Redis = (await import("ioredis")).default; + queue = new RedisMessageQueue(() => new Redis(redisUrl)); + console.info("[ActivityPub] Using Redis message queue"); +} else { + queue = new InProcessMessageQueue(); + console.warn( + "[ActivityPub] Using in-process message queue (not recommended for production)", + ); +} + +const federation = createFederation({ + kv: new MongoKvStore(collections.ap_kv), + queue, +}); +``` + +**Step 2: Add `redisUrl` to the destructured options** + +Find: + +```javascript +const { + collections, + mountPath, + handle, + storeRawActivities = false, +} = options; +``` + +Replace with: + +```javascript +const { + collections, + mountPath, + handle, + storeRawActivities = false, + redisUrl = "", +} = options; +``` + +**Step 3: Verify locally** + +Without Redis: Plugin should log the "in-process" warning and work as before. +With Redis: Plugin should log "Using Redis message queue". + +**Step 4: Commit** + +``` +feat(redis): use RedisMessageQueue when redisUrl is configured + +Falls back to InProcessMessageQueue when Redis is not available. +Redis provides persistent, retry-capable delivery that survives +process restarts — critical for reliable federation. +``` + +--- + +### Task 5: Add `Reject` inbox listener + +**Files:** +- Modify: `lib/inbox-listeners.js` + +**Context:** When a remote server rejects our Follow request, the `ap_following` entry stays as `refollow:sent` forever. A `Reject` listener should mark it as rejected and clean up. + +**Step 1: Add `Reject` to imports** + +Find: + +```javascript +import { + Accept, + Add, + Announce, + Block, + Create, + Delete, + Follow, + Like, + Move, + Note, + Remove, + Undo, + Update, +} from "@fedify/fedify"; +``` + +Add `Reject`: + +```javascript +import { + Accept, + Add, + Announce, + Block, + Create, + Delete, + Follow, + Like, + Move, + Note, + Reject, + Remove, + Undo, + Update, +} from "@fedify/fedify"; +``` + +**Step 2: Add the listener after the `Accept` handler** + +After the `.on(Accept, ...)` block (around line 162), add: + +```javascript + .on(Reject, async (ctx, reject) => { + const actorObj = await reject.getActor(); + const actorUrl = actorObj?.id?.href || ""; + if (!actorUrl) return; + + // Mark rejected follow in ap_following + const result = await collections.ap_following.findOneAndUpdate( + { + actorUrl, + source: { $in: ["refollow:sent", "microsub-reader"] }, + }, + { + $set: { + source: "rejected", + rejectedAt: new Date().toISOString(), + }, + }, + { returnDocument: "after" }, + ); + + if (result) { + const actorName = result.name || result.handle || actorUrl; + await logActivity(collections, storeRawActivities, { + direction: "inbound", + type: "Reject(Follow)", + actorUrl, + actorName, + summary: `${actorName} rejected our Follow`, + }); + } + }) +``` + +**Step 3: Verify** + +Check the plugin loads without errors. The Reject handler will activate when a remote server sends a Reject activity in response to a Follow. + +**Step 4: Commit** + +``` +feat(inbox): add Reject listener for rejected Follow requests + +Marks ap_following entries as "rejected" instead of leaving them +stuck in "refollow:sent" state indefinitely. +``` + +--- + +### Task 6: Add database indexes for query performance + +**Files:** +- Modify: `index.js` (in the `init()` method, after the TTL index creation) + +**Context:** With 830+ followers and 2500+ following, unindexed queries on `actorUrl`, `source`, and `objectUrl` are doing full collection scans. MongoDB uses these fields for lookups in every inbox activity handler. + +**Step 1: Add indexes after the existing TTL index block** + +Find the TTL index block in `init()` (around line 612-618): + +```javascript +if (retentionDays > 0) { + this._collections.ap_activities.createIndex( + { receivedAt: 1 }, + { expireAfterSeconds: retentionDays * 86_400 }, + ); +} +``` + +Add immediately after: + +```javascript +// Performance indexes for inbox handlers and batch refollow +this._collections.ap_followers.createIndex( + { actorUrl: 1 }, + { unique: true, background: true }, +); +this._collections.ap_following.createIndex( + { actorUrl: 1 }, + { unique: true, background: true }, +); +this._collections.ap_following.createIndex( + { source: 1 }, + { background: true }, +); +this._collections.ap_activities.createIndex( + { objectUrl: 1 }, + { background: true }, +); +this._collections.ap_activities.createIndex( + { type: 1, actorUrl: 1, objectUrl: 1 }, + { background: true }, +); +``` + +**Step 2: Verify** + +These are idempotent — `createIndex` on an existing index is a no-op. After deploying, verify with: + +```bash +cloudron exec --app rmendes.net -- bash -c 'mongosh "$CLOUDRON_MONGODB_URL" --quiet --eval " + db.ap_followers.getIndexes().forEach(i => print(JSON.stringify(i.key))); + db.ap_following.getIndexes().forEach(i => print(JSON.stringify(i.key))); + db.ap_activities.getIndexes().forEach(i => print(JSON.stringify(i.key))); +"' +``` + +**Step 3: Commit** + +``` +perf(db): add indexes for ap_followers, ap_following, ap_activities + +Prevents collection scans on actorUrl lookups (every inbox +activity), source queries (batch refollow), and objectUrl +deletions (Delete handler). Critical at 830+ followers. +``` + +--- + +### Task 7: Wire up `storeRawActivities` flag in activity logging + +**Files:** +- Modify: `lib/activity-log.js` +- Modify: `lib/inbox-listeners.js` (the local `logActivity` wrapper) + +**Context:** The `storeRawActivities` config option is accepted and threaded through to inbox listeners, but the local `logActivity` wrapper silently ignores it. The `logActivityShared` function in `activity-log.js` doesn't accept a raw JSON parameter either. + +**Step 1: Read the current `activity-log.js`** + +Current file: + +```javascript +export async function logActivity(collection, record) { + await collection.insertOne({ + ...record, + receivedAt: new Date().toISOString(), + }); +} +``` + +**Step 2: Add optional `rawJson` field support** + +Replace: + +```javascript +export async function logActivity(collection, record) { + await collection.insertOne({ + ...record, + receivedAt: new Date().toISOString(), + }); +} +``` + +With: + +```javascript +/** + * Log an activity to the ap_activities collection. + * + * @param {import("mongodb").Collection} collection + * @param {object} record - Activity fields (direction, type, actorUrl, etc.) + * @param {object} [options] + * @param {object} [options.rawJson] - Full raw JSON to store (when storeRawActivities is on) + */ +export async function logActivity(collection, record, options = {}) { + const doc = { + ...record, + receivedAt: new Date().toISOString(), + }; + if (options.rawJson) { + doc.rawJson = options.rawJson; + } + await collection.insertOne(doc); +} +``` + +**Step 3: Update the wrapper in `inbox-listeners.js`** + +Find: + +```javascript +async function logActivity(collections, storeRaw, record) { + await logActivityShared(collections.ap_activities, record); +} +``` + +Replace with: + +```javascript +async function logActivity(collections, storeRaw, record, rawJson) { + await logActivityShared( + collections.ap_activities, + record, + storeRaw && rawJson ? { rawJson } : {}, + ); +} +``` + +**Step 4: Commit** + +``` +fix(log): wire storeRawActivities flag through to activity log + +The config option was accepted but silently ignored. Now passes +raw JSON to the activity log when enabled. +``` + +--- + +### Task 8: Update Cloudron deployment config to pass `redisUrl` + +**Files:** +- Modify: `/home/rick/code/indiekit-dev/indiekit-cloudron/indiekit.config.js.rmendes` (ActivityPub section) +- Modify: `/home/rick/code/indiekit-dev/indiekit-cloudron/indiekit.config.js.template` (ActivityPub section) +- Modify: `/home/rick/code/indiekit-dev/indiekit-cloudron/Dockerfile` (bump plugin version) + +**Context:** The Cloudron container already has `CLOUDRON_REDIS_URL` available. The plugin needs it passed through as `redisUrl` in its config section. + +**Step 1: Update `.rmendes` config** + +In the ActivityPub config block, add `redisUrl`: + +```javascript + "@rmdes/indiekit-endpoint-activitypub": { + mountPath: "/activitypub", + actor: { + handle: "rick", + name: "Ricardo Mendes", + summary: "Personal website of Ricardo Mendes", + icon: "https://rmendes.net/images/user/avatar.jpg", + }, + checked: true, + alsoKnownAs: "", + activityRetentionDays: 90, + storeRawActivities: false, + redisUrl: process.env.CLOUDRON_REDIS_URL || "", + }, +``` + +**Step 2: Update `.template` config** + +Add the same `redisUrl` line to the template's ActivityPub section (if present), or add a full ActivityPub config block. + +**Step 3: Bump plugin version in Dockerfile** + +Update the `npm install` line in the Dockerfile to reference the new version once published. + +**Step 4: Commit (in indiekit-cloudron repo)** + +``` +feat: pass Redis URL to ActivityPub endpoint for persistent queue + +Cloudron provides CLOUDRON_REDIS_URL from the Redis addon. +The ActivityPub plugin uses it for RedisMessageQueue, which +survives process restarts unlike InProcessMessageQueue. +``` + +--- + +### Task 9: Bump plugin version and publish + +**Files:** +- Modify: `package.json` (version field) + +**Step 1: Bump version** + +```bash +cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub +# Bump from 1.0.20 to 1.0.21 +``` + +Update `"version": "1.0.21"` in package.json. + +**Step 2: Commit all changes** + +``` +chore: bump version to 1.0.21 +``` + +**Step 3: Push and publish** + +```bash +git push origin main +``` + +Then **STOP — user must run `npm publish`** (requires OTP). + +--- + +### Task 10: Deploy and run federation test suite + +**Step 1: After user confirms publish, update Dockerfile and deploy** + +```bash +cd /home/rick/code/indiekit-dev/indiekit-cloudron +cloudron build --no-cache && cloudron update --app rmendes.net --no-backup +``` + +**Step 2: Verify Redis is connected** + +```bash +cloudron logs -f --app rmendes.net | grep -i "redis\|message queue" +``` + +Expected: `[ActivityPub] Using Redis message queue` + +**Step 3: Run the test suite** + +```bash +cd /home/rick/code/indiekit-dev/activitypub-tests +./run-all.sh +``` + +Expected: 12/12 passing. + +**Step 4: Verify Ed25519 key persistence** + +```bash +cloudron exec --app rmendes.net -- bash -c 'mongosh "$CLOUDRON_MONGODB_URL" --quiet --eval " + db.ap_keys.find({ type: \"ed25519\" }).toArray() +"' +``` + +Expected: One document with `publicKeyJwk` and `privateKeyJwk` fields. + +**Step 5: Verify indexes** + +```bash +cloudron exec --app rmendes.net -- bash -c 'mongosh "$CLOUDRON_MONGODB_URL" --quiet --eval " + print(\"ap_followers indexes:\"); + db.ap_followers.getIndexes().forEach(i => print(\" \" + JSON.stringify(i.key))); + print(\"ap_following indexes:\"); + db.ap_following.getIndexes().forEach(i => print(\" \" + JSON.stringify(i.key))); + print(\"ap_activities indexes:\"); + db.ap_activities.getIndexes().forEach(i => print(\" \" + JSON.stringify(i.key))); +"' +``` + +Expected: Indexes on `actorUrl`, `source`, `objectUrl`, and the compound `{ type, actorUrl, objectUrl }`. diff --git a/docs/plans/2026-02-20-fedify-feature-completeness.md b/docs/plans/2026-02-20-fedify-feature-completeness.md new file mode 100644 index 0000000..8846149 --- /dev/null +++ b/docs/plans/2026-02-20-fedify-feature-completeness.md @@ -0,0 +1,650 @@ +# Fedify Feature Completeness Implementation Plan + +Created: 2026-02-20 +Status: COMPLETE +Approved: Yes +Iterations: 0 +Worktree: No + +> **Status Lifecycle:** PENDING → COMPLETE → VERIFIED +> **Iterations:** Tracks implement→verify cycles (incremented by verify phase) +> +> - PENDING: Initial state, awaiting implementation +> - COMPLETE: All tasks implemented +> - VERIFIED: All checks passed +> +> **Approval Gate:** Implementation CANNOT proceed until `Approved: Yes` +> **Worktree:** Set at plan creation (from dispatcher). `Yes` uses git worktree isolation; `No` works directly on current branch (default) + +## Summary + +**Goal:** Implement all missing Fedify features in the `@rmdes/indiekit-endpoint-activitypub` plugin to achieve near-complete Fedify API coverage — delivery reliability (permanent failure handling, ordering keys, collection sync), content resolution (object dispatcher), standard collections (liked, featured, featured tags), access control (authorized fetch, instance actor), and quality-of-life improvements (dynamic NodeInfo, parallel queue, handle aliases). + +**Architecture:** All changes are additive to the existing `federation-setup.js` architecture. New dispatchers/handlers are registered on the same `federation` instance returned by `createFederation()`. New MongoDB collections are added via `Indiekit.addCollection()` in `index.js`. Admin UI views follow the existing Nunjucks template pattern in `views/`. The plugin's syndicator, inbox listeners, and existing collection dispatchers remain unchanged. + +**Tech Stack:** Fedify ^1.10.0, @fedify/redis ^1.10.3, MongoDB, Express 5, Nunjucks templates, ioredis + +## Scope + +### In Scope + +- Permanent failure handler for dead inboxes +- Followers collection sync (FEP-8fcf) on sendActivity +- Ordering keys on all sendActivity calls +- Object dispatcher for Note/Article resolution +- Liked collection dispatcher +- Featured (pinned) collection dispatcher + admin UI +- Featured tags collection dispatcher + admin UI +- Instance actor (Application type) for domain-level federation +- Authorized fetch with admin toggle +- ParallelMessageQueue wrapper +- Dynamic NodeInfo version from package.json +- Handle aliases via mapAlias +- Configurable actor type (Person/Service/Application) +- Context data propagation (handle + publicationUrl) +- Investigate and fix syndication delivery issues + +### Out of Scope + +- Custom collections API (extensible plugin system for registering arbitrary collections) +- Relay support (FEP-ae0c) +- Key rotation admin UI (deferred — keys are already dual RSA+Ed25519) +- Custom WebFinger links +- Separate inbox/outbox queue configuration + +## Prerequisites + +- Plugin repo at `/home/rick/code/indiekit-dev/indiekit-endpoint-activitypub` +- Cloudron deployment at `/home/rick/code/indiekit-dev/indiekit-cloudron` +- Fedify docs at `/home/rick/code/fedify/docs/manual/` +- Test suite at `/home/rick/code/indiekit-dev/activitypub-tests/` +- Node.js 22, MongoDB, Redis available on Cloudron + +## Context for Implementer + +> This section is critical for cross-session continuity. Write it for an implementer who has never seen the codebase. + +- **Patterns to follow:** All Fedify dispatchers are registered in `lib/federation-setup.js` using the pattern `federation.setXxxDispatcher(urlPattern, callback)` with `.setCounter()` and `.setFirstCursor()` chained (see `setupFollowers` at line 285, `setupOutbox` at line 345). +- **Conventions:** ESM modules (`"type": "module"`), no build step. MongoDB collections registered via `Indiekit.addCollection("name")` in `index.js:init()`. Dates stored as ISO strings. Admin views are Nunjucks templates in `views/` rendered by controllers in `lib/controllers/`. +- **Key files:** + - `index.js` — Plugin entry point, constructor defaults, `init()`, syndicator, routes + - `lib/federation-setup.js` — All Fedify configuration (actor, inbox, collections, NodeInfo) + - `lib/inbox-listeners.js` — Inbox activity handlers (Follow, Undo, Like, etc.) + - `lib/jf2-to-as2.js` — Converts Indiekit JF2 posts to ActivityStreams objects + - `lib/activity-log.js` — Logs activities to `ap_activities` collection + - `lib/kv-store.js` — MongoDB-backed KvStore for Fedify + - `package.json` — Dependencies, version (currently 1.0.21) +- **Gotchas:** + - `init()` is synchronous — cannot use `await`. All async work (profile seeding, batch refollow) uses `.catch()` or `setTimeout()`. + - The syndicator's `syndicate(properties)` returns the post URL on success or `undefined` on skip/failure. + - `createFederation()` options like `onOutboxError` and `permanentFailureStatusCodes` are set at creation time, not after. + - The `setOutboxPermanentFailureHandler()` is called on the federation instance AFTER creation. + - `ctx.sendActivity()` options: `{ preferSharedInbox, syncCollection, orderingKey }`. +- **Domain context:** Indiekit stores posts as JF2 objects in MongoDB `posts` collection with `properties.url`, `properties.published`, `properties["post-type"]`, etc. The `jf2ToAS2Activity()` function converts these to Fedify Activity objects. + +## Runtime Environment + +- **Start command:** `node --import @indiekit/indiekit/register index.js` (via Cloudron start.sh) +- **Port:** 8080 (Indiekit), 3000 (nginx) +- **Deploy path:** Cloudron at rmendes.net +- **Health check:** `curl -s https://rmendes.net/.well-known/webfinger?resource=acct:rick@rmendes.net` +- **Restart procedure:** `cloudron build --no-cache && cloudron update --app rmendes.net --no-backup` + +## Progress Tracking + +**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.** + +- [x] Task 1: Investigate and fix syndication delivery +- [x] Task 2: Permanent failure handler (SKIPPED — requires Fedify 2.0+) +- [x] Task 3: Ordering keys on sendActivity +- [x] Task 4: Followers collection sync (FEP-8fcf) +- [x] Task 5: Dynamic NodeInfo version +- [x] Task 6: Context data propagation +- [x] Task 7: Object dispatcher for Note/Article +- [x] Task 8: Liked collection +- [x] Task 9: Featured (pinned) collection + admin UI +- [x] Task 10: Featured tags collection + admin UI +- [x] Task 11: Instance actor +- [x] Task 12: Authorized fetch with admin toggle +- [x] Task 13: ParallelMessageQueue +- [x] Task 14: Handle aliases (mapAlias) +- [x] Task 15: Configurable actor type +- [x] Task 16: New test scripts +- [x] Task 17: Version bump + Cloudron config update + +**Total Tasks:** 17 | **Completed:** 17 | **Remaining:** 0 + +## Implementation Tasks + +### Task 1: Investigate and fix syndication delivery + +**Objective:** The user reports new posts are not appearing on their fediverse profile despite syndication logs showing "Sent Create to 830 followers". Investigate whether the issue is delivery (queue not processing), content resolution (remote servers can't fetch the post), or something else entirely. + +**Dependencies:** None + +**Files:** +- Investigate: `lib/federation-setup.js` (queue startup, sendActivity) +- Investigate: `index.js` (syndicator.syndicate method) +- Investigate: `lib/jf2-to-as2.js` (activity structure) + +**Key Decisions / Notes:** +- Check if `federation.startQueue()` is working correctly +- Check if the activities in the Redis queue are actually being processed (not just enqueued) +- Test whether a remote Mastodon instance can fetch the content negotiation response and display it +- Check if the `Create` activity wrapping is correct (Mastodon requires `Create(Note)`, not bare `Note`) +- Use `fedify lookup` CLI to test if the actor and posts resolve correctly from external perspective + +**Definition of Done:** +- [ ] Root cause of syndication failure identified +- [ ] Fix implemented (if code change needed) +- [ ] Verified a new post appears on fediverse profile after syndication + +**Verify:** +- `fedify lookup acct:rick@rmendes.net` — actor resolves with outbox +- `curl -s -H "Accept: application/activity+json" https://rmendes.net/notes/YYYY/MM/DD/slug` — returns valid AS2 Note +- Check Mastodon instance shows the post + +--- + +### Task 2: Permanent failure handler + +**Objective:** Register `setOutboxPermanentFailureHandler()` on the federation instance to automatically remove followers whose inboxes return 404/410. Log these events to `ap_activities`. + +**Dependencies:** None + +**Files:** +- Modify: `lib/federation-setup.js` — add handler after `createFederation()` +- Modify: `lib/federation-setup.js` — add `permanentFailureStatusCodes: [404, 410, 451]` to createFederation options + +**Key Decisions / Notes:** +- The handler receives `{ inbox, activity, error, statusCode, actorIds }` via `values` parameter +- `values.actorIds` are URL objects — match against `ap_followers.actorUrl` (string comparison with `.href`) +- When `preferSharedInbox: true`, one inbox may represent multiple followers +- Log each removal as an activity with type "PermanentFailure" +- Don't throw errors in the handler (Fedify catches and ignores them anyway) + +**Definition of Done:** +- [ ] `setOutboxPermanentFailureHandler` registered on federation instance +- [ ] Dead followers removed from `ap_followers` when inbox returns 404/410/451 +- [ ] Permanent failures logged to `ap_activities` with direction "system" +- [ ] `permanentFailureStatusCodes` set to `[404, 410, 451]` + +**Verify:** +- Check that `permanentFailureStatusCodes` appears in `createFederation()` call +- Check that `setOutboxPermanentFailureHandler` is called on the federation instance +- `node -e "import('./lib/federation-setup.js')"` — no import errors + +--- + +### Task 3: Ordering keys on sendActivity + +**Objective:** Add `orderingKey` to all `ctx.sendActivity()` calls so that related activities (Create→Update→Delete for the same post) are delivered in order per recipient server. + +**Dependencies:** None + +**Files:** +- Modify: `index.js` — syndicator.syndicate method (line ~331 and ~341) +- Modify: `index.js` — followActor method (line ~426) +- Modify: `index.js` — unfollowActor method (line ~534) + +**Key Decisions / Notes:** +- For post syndication: `orderingKey: properties.url` — ensures Create/Update/Delete for same post are ordered +- For follow/unfollow: `orderingKey: actorUrl` — ensures Follow then Undo(Follow) arrive in order +- Don't add ordering keys for unrelated activities (reduces parallelism) +- The ordering key is per-recipient-server — two servers can receive in parallel + +**Definition of Done:** +- [ ] `orderingKey` added to sendActivity in syndicator.syndicate (both followers and reply-to-author calls) +- [ ] `orderingKey` added to sendActivity in followActor +- [ ] `orderingKey` added to sendActivity in unfollowActor + +**Verify:** +- Grep for `sendActivity` — every call should have `orderingKey` in its options +- `node -e "import('./index.js')"` — no import errors + +--- + +### Task 4: Followers collection sync (FEP-8fcf) + +**Objective:** Add `preferSharedInbox: true` and `syncCollection: true` to the `sendActivity` call that sends to `"followers"` in the syndicator. + +**Dependencies:** None + +**Files:** +- Modify: `index.js` — syndicator.syndicate method, the `ctx.sendActivity({ identifier: handle }, "followers", activity)` call (~line 331) + +**Key Decisions / Notes:** +- `syncCollection: true` is ONLY valid when recipients is `"followers"` (string) +- `preferSharedInbox: true` consolidates delivery to shared inboxes (more efficient) +- Fedify automatically includes a followers collection digest in the delivery payload +- This implements FEP-8fcf for Mastodon-compatible servers + +**Definition of Done:** +- [ ] `sendActivity` to `"followers"` includes `{ preferSharedInbox: true, syncCollection: true }` +- [ ] Other `sendActivity` calls (to specific actors) do NOT include `syncCollection` + +**Verify:** +- Read the syndicate method and confirm the options are present on the followers call only + +--- + +### Task 5: Dynamic NodeInfo version + +**Objective:** Read the actual Indiekit version from `@indiekit/indiekit` package.json instead of hardcoding `{ major: 1, minor: 0, patch: 0 }`. + +**Dependencies:** None + +**Files:** +- Modify: `lib/federation-setup.js` — NodeInfo dispatcher (~line 253) + +**Key Decisions / Notes:** +- Use `import { createRequire } from "node:module"` and `createRequire(import.meta.url)` to resolve `@indiekit/indiekit/package.json` +- Or use a simpler approach: read the version from `@indiekit/indiekit` package.json at module load time +- Parse semver string "1.0.0-beta.25" → `{ major: 1, minor: 0, patch: 0 }` (ignore prerelease) +- Fallback to `{ major: 1, minor: 0, patch: 0 }` if resolution fails + +**Definition of Done:** +- [ ] NodeInfo dispatcher reads version from @indiekit/indiekit package.json +- [ ] Version parsed correctly into `{ major, minor, patch }` format +- [ ] Fallback to 1.0.0 if package.json can't be found + +**Verify:** +- `curl -s https://rmendes.net/nodeinfo/2.1 | jq .software.version` — should return actual version +- Test script: `tests/02-nodeinfo.sh` passes + +--- + +### Task 6: Context data propagation + +**Objective:** Pass `handle` and `publicationUrl` in the Fedify context data instead of using closures, so dispatchers and handlers have cleaner access to these values. + +**Dependencies:** None + +**Files:** +- Modify: `lib/federation-setup.js` — `createFederation()` and all `createContext()` calls +- Modify: `index.js` — all `this._federation.createContext()` calls in syndicator, followActor, unfollowActor + +**Key Decisions / Notes:** +- Currently `createContext(new URL(publicationUrl), {})` passes empty context +- Change to `createContext(new URL(publicationUrl), { handle, publicationUrl })` +- This doesn't change any behavior — it just makes the data available via `ctx.data` in dispatchers +- Future tasks (object dispatcher, collections) will use `ctx.data.handle` and `ctx.data.publicationUrl` + +**Definition of Done:** +- [ ] `createContext()` calls pass `{ handle, publicationUrl }` as context data +- [ ] No behavioral changes — existing functionality preserved + +**Verify:** +- `node -e "import('./index.js')"` — no import errors +- Existing test suite passes + +--- + +### Task 7: Object dispatcher for Note/Article + +**Objective:** Register `setObjectDispatcher()` for `Note` and `Article` types so individual posts are dereferenceable at proper Fedify-managed URIs. This is critical for remote servers to properly display shared content. + +**Dependencies:** Task 6 (context data propagation) + +**Files:** +- Modify: `lib/federation-setup.js` — add `setupObjectDispatchers()` function and call it +- Read: `lib/jf2-to-as2.js` — understand how posts are converted to AS2 + +**Key Decisions / Notes:** +- Register `federation.setObjectDispatcher(Note, ...)` and `federation.setObjectDispatcher(Article, ...)` +- URL pattern: `${mountPath}/objects/note/{id}` and `${mountPath}/objects/article/{id}` +- The `{id}` maps to the post's URL slug (e.g., `notes/2026/02/20/52ef4`) +- Dispatcher looks up the post in MongoDB `posts` collection by URL and converts to AS2 +- Use `{+id}` (reserved expansion) since IDs contain slashes +- This complements the existing content-negotiation route — Fedify handles proper ActivityPub discovery while content negotiation handles direct URL requests + +**Definition of Done:** +- [ ] `setObjectDispatcher(Note, ...)` registered for note/reply/like/repost/bookmark/jam/rsvp posts +- [ ] `setObjectDispatcher(Article, ...)` registered for article posts +- [ ] Dispatcher looks up post in MongoDB and returns proper Fedify Note/Article object +- [ ] Actor dispatcher references `liked` and `featured` URIs (prepared for later tasks) + +**Verify:** +- `curl -s -H "Accept: application/activity+json" "https://rmendes.net/activitypub/objects/note/notes%2F2026%2F02%2F20%2F52ef4"` — returns AS2 Note +- Existing content negotiation still works + +--- + +### Task 8: Liked collection + +**Objective:** Expose a `liked` collection showing objects the actor has liked. Query the MongoDB `posts` collection for `post-type: "like"` posts and return their `like-of` URLs. + +**Dependencies:** Task 6 + +**Files:** +- Modify: `lib/federation-setup.js` — add `setupLiked()` function +- Modify: `lib/federation-setup.js` — call `setupLiked()` in `setupFederation()` +- Modify: actor dispatcher to include `liked: ctx.getLikedUri(identifier)` + +**Key Decisions / Notes:** +- Pattern: `${mountPath}/users/{identifier}/liked` +- Query: `collections.posts.find({ "properties.post-type": "like" })` sorted by published desc +- Return items as URLs: `new URL(post.properties["like-of"])` for each like post +- Include `.setCounter()` and `.setFirstCursor()` for pagination (same pattern as followers) +- Add `liked` property to the Person actor options + +**Definition of Done:** +- [ ] `setLikedDispatcher` registered with pagination +- [ ] Actor includes `liked` URI in Person properties +- [ ] Collection returns liked object URLs + +**Verify:** +- `curl -s -H "Accept: application/activity+json" "https://rmendes.net/activitypub/users/rick/liked"` — returns OrderedCollection +- New test script added + +--- + +### Task 9: Featured (pinned) collection + admin UI + +**Objective:** Expose a `featured` collection for pinned posts and add an admin UI to manage them. Store pinned post URLs in a new `ap_featured` MongoDB collection. + +**Dependencies:** Task 6 + +**Files:** +- Modify: `lib/federation-setup.js` — add `setupFeatured()` function +- Modify: `index.js` — register `ap_featured` collection, add admin routes +- Create: `lib/controllers/featured.js` — admin controller for pin/unpin +- Create: `views/featured.njk` — admin template for managing pinned posts +- Modify: actor dispatcher to include `featured: ctx.getFeaturedUri(identifier)` + +**Key Decisions / Notes:** +- New collection `ap_featured` stores `{ postUrl, pinnedAt }` documents +- Pattern: `${mountPath}/users/{identifier}/featured` +- Dispatcher returns the full Note/Article objects (not just URLs) — Mastodon expects objects +- Admin UI at `/activitypub/admin/featured` — list pinned posts, pin/unpin buttons +- Pin limit: 5 posts max (Mastodon convention) +- On pin: look up post in `posts` collection, convert to AS2 Note/Article, store URL in `ap_featured` +- On unpin: remove from `ap_featured` + +**Definition of Done:** +- [ ] `setFeaturedDispatcher` registered, returns AS2 objects for pinned posts +- [ ] `ap_featured` MongoDB collection registered +- [ ] Admin UI at `/activitypub/admin/featured` to manage pins +- [ ] Actor includes `featured` URI in Person properties +- [ ] Pin limit of 5 enforced + +**Verify:** +- `curl -s -H "Accept: application/activity+json" "https://rmendes.net/activitypub/users/rick/featured"` — returns OrderedCollection +- Admin UI renders at `/activitypub/admin/featured` + +--- + +### Task 10: Featured tags collection + admin UI + +**Objective:** Expose a `featured tags` collection for hashtags the actor wants to highlight. Store them in a new `ap_featured_tags` MongoDB collection with an admin UI. + +**Dependencies:** Task 6 + +**Files:** +- Modify: `lib/federation-setup.js` — add `setupFeaturedTags()` function +- Modify: `index.js` — register `ap_featured_tags` collection, add admin routes +- Create: `lib/controllers/featured-tags.js` — admin controller +- Create: `views/featured-tags.njk` — admin template +- Modify: actor dispatcher to include `featuredTags: ctx.getFeaturedTagsUri(identifier)` + +**Key Decisions / Notes:** +- New collection `ap_featured_tags` stores `{ tag, addedAt }` documents +- Pattern: `${mountPath}/users/{identifier}/tags` +- Dispatcher returns `Hashtag` objects with `name` (`#tag`) and `href` (link to tag page) +- Import `Hashtag` from `@fedify/fedify` +- Admin UI at `/activitypub/admin/tags` — add/remove featured tags +- Tag href: `${publicationUrl}categories/${encodeURIComponent(tag)}` + +**Definition of Done:** +- [ ] `setFeaturedTagsDispatcher` registered, returns Hashtag objects +- [ ] `ap_featured_tags` MongoDB collection registered +- [ ] Admin UI at `/activitypub/admin/tags` to manage featured tags +- [ ] Actor includes `featuredTags` URI + +**Verify:** +- `curl -s -H "Accept: application/activity+json" "https://rmendes.net/activitypub/users/rick/tags"` — returns Collection of Hashtags +- Admin UI renders + +--- + +### Task 11: Instance actor + +**Objective:** Create an Application-type instance actor (`rmendes.net@rmendes.net`) that represents the domain itself. This is required for authorized fetch to work without infinite loops. + +**Dependencies:** Task 6 + +**Files:** +- Modify: `lib/federation-setup.js` — extend actor dispatcher to handle instance actor identifier +- Modify: `lib/federation-setup.js` — extend key pairs dispatcher for instance actor +- Modify: `lib/federation-setup.js` — extend `mapHandle` to accept hostname + +**Key Decisions / Notes:** +- When `identifier === ctx.hostname` (e.g., "rmendes.net"), return an `Application` actor +- Import `Application` from `@fedify/fedify` +- Instance actor uses the same RSA+Ed25519 key pairs as the main actor (simplicity) +- Instance actor properties: `id`, `preferredUsername: hostname`, `inbox`, `outbox` (empty) +- `mapHandle` returns hostname when username matches hostname +- The instance actor does NOT need followers/following/liked/featured collections + +**Definition of Done:** +- [ ] Actor dispatcher returns `Application` when identifier is hostname +- [ ] Key pairs dispatcher returns keys for hostname identifier +- [ ] `mapHandle` accepts hostname as valid username +- [ ] Instance actor resolves via WebFinger: `acct:rmendes.net@rmendes.net` + +**Verify:** +- `fedify lookup acct:rmendes.net@rmendes.net` — returns Application actor +- `curl -s -H "Accept: application/activity+json" "https://rmendes.net/activitypub/users/rmendes.net"` — returns Application + +--- + +### Task 12: Authorized fetch with admin toggle + +**Objective:** Add optional authorized fetch support via `.authorize()` predicates on the actor and collection dispatchers. Controlled by a config option and admin toggle. + +**Dependencies:** Task 11 (instance actor needed to prevent infinite loops) + +**Files:** +- Modify: `lib/federation-setup.js` — add `.authorize()` to actor, collections +- Modify: `index.js` — add `authorizedFetch: false` to defaults, pass to setupFederation +- Modify: `views/dashboard.njk` or `views/profile.njk` — add toggle (or use ap_profile) + +**Key Decisions / Notes:** +- When `authorizedFetch` is enabled: + - Actor dispatcher: `.authorize()` returns true for instance actor, checks signed key for others + - Collections: `.authorize()` same logic +- When `authorizedFetch` is disabled (default): don't chain `.authorize()` at all +- Store setting in `ap_profile.authorizedFetch` (boolean) +- The `authorize` predicate: `ctx.getSignedKeyOwner({ documentLoader: await ctx.getDocumentLoader({ identifier: ctx.hostname }) })` +- Instance actor is always accessible without auth (prevents infinite loops) + +**Definition of Done:** +- [ ] `.authorize()` chained on actor and collection dispatchers when enabled +- [ ] Instance actor always returns true (no auth required) +- [ ] Config option `authorizedFetch` defaults to false +- [ ] Setting stored in ap_profile for runtime toggle + +**Verify:** +- With `authorizedFetch: false` — unsigned GET requests work normally +- Check that `.authorize()` is conditionally applied + +--- + +### Task 13: ParallelMessageQueue + +**Objective:** Wrap the Redis message queue with `ParallelMessageQueue` for concurrent activity processing. Add a config option for the number of workers. + +**Dependencies:** None + +**Files:** +- Modify: `lib/federation-setup.js` — wrap queue with ParallelMessageQueue +- Modify: `index.js` — add `parallelWorkers: 5` to defaults + +**Key Decisions / Notes:** +- Import `ParallelMessageQueue` from `@fedify/fedify` +- When `redisUrl` is set AND `parallelWorkers > 1`: wrap `RedisMessageQueue` with `ParallelMessageQueue` +- When `parallelWorkers <= 1` or no Redis: don't wrap (single worker) +- Default: 5 workers (good balance for ~800 followers) +- `ParallelMessageQueue` inherits `nativeRetrial` from the wrapped queue + +**Definition of Done:** +- [ ] `ParallelMessageQueue` wraps Redis queue when parallelWorkers > 1 +- [ ] Config option `parallelWorkers` with default 5 +- [ ] InProcessMessageQueue is NOT wrapped (only used in dev) + +**Verify:** +- Console log shows "Using Redis message queue with 5 parallel workers" +- `node -e "import('./lib/federation-setup.js')"` — no import errors + +--- + +### Task 14: Handle aliases (mapAlias) + +**Objective:** Register `mapAlias()` so that the actor's profile URL and common alias patterns resolve via WebFinger. + +**Dependencies:** None + +**Files:** +- Modify: `lib/federation-setup.js` — chain `.mapAlias()` on actor dispatcher + +**Key Decisions / Notes:** +- When someone queries WebFinger for the profile URL (e.g., `https://rmendes.net/`), resolve to the actor +- Pattern: check if `resource.hostname` matches and `resource.pathname` is `/` or `/@handle` +- Return `{ identifier: handle }` for matching URLs +- This allows `https://rmendes.net/` to be discoverable via WebFinger alongside `acct:rick@rmendes.net` + +**Definition of Done:** +- [ ] `mapAlias()` registered on actor dispatcher +- [ ] Profile URL resolves via WebFinger +- [ ] `/@handle` pattern resolves via WebFinger + +**Verify:** +- `curl -s "https://rmendes.net/.well-known/webfinger?resource=https://rmendes.net/"` — returns actor link +- `curl -s "https://rmendes.net/.well-known/webfinger?resource=https://rmendes.net/@rick"` — returns actor link + +--- + +### Task 15: Configurable actor type + +**Objective:** Add a config option to choose the actor type (Person, Service, Application) instead of hardcoding Person. + +**Dependencies:** Task 11 (instance actor uses Application) + +**Files:** +- Modify: `lib/federation-setup.js` — use config-based actor class +- Modify: `index.js` — add `actorType: "Person"` to defaults + +**Key Decisions / Notes:** +- Import `Person`, `Service`, `Application`, `Organization`, `Group` from `@fedify/fedify` +- Map string config to class: `{ Person, Service, Application, Organization, Group }` +- Instance actor always uses `Application` regardless of config +- Default: "Person" (most common for individual blogs) + +**Definition of Done:** +- [ ] Config option `actorType` with default "Person" +- [ ] Actor dispatcher uses configured type class +- [ ] Instance actor always uses Application + +**Verify:** +- Actor endpoint returns `type: "Person"` by default +- Config change to "Service" would change the type field + +--- + +### Task 16: New test scripts + +**Objective:** Add test scripts for the new features: liked collection, featured collection, featured tags, instance actor, and handle aliases. + +**Dependencies:** Tasks 7-14 + +**Files:** +- Create: `activitypub-tests/tests/13-liked.sh` +- Create: `activitypub-tests/tests/14-featured.sh` +- Create: `activitypub-tests/tests/15-featured-tags.sh` +- Create: `activitypub-tests/tests/16-instance-actor.sh` +- Create: `activitypub-tests/tests/17-object-dispatcher.sh` +- Create: `activitypub-tests/tests/18-webfinger-alias.sh` + +**Key Decisions / Notes:** +- Follow existing test pattern in `tests/common.sh` (BASE_URL, curl, jq assertions) +- Each test verifies the HTTP response and JSON structure +- Liked: GET liked collection, verify OrderedCollection type +- Featured: GET featured collection, verify OrderedCollection type +- Featured tags: GET tags collection, verify items are Hashtag type +- Instance actor: WebFinger + actor endpoint for hostname identifier +- Object dispatcher: GET object URI, verify Note/Article type +- WebFinger alias: query WebFinger with profile URL + +**Definition of Done:** +- [ ] All 6 test scripts created and passing +- [ ] `run-all.sh` updated to include new tests + +**Verify:** +- `cd activitypub-tests && bash run-all.sh` — all tests pass + +--- + +### Task 17: Version bump + Cloudron config update + +**Objective:** Bump plugin version, update Cloudron Dockerfile and config files, prepare for deployment. + +**Dependencies:** All previous tasks + +**Files:** +- Modify: `package.json` — bump version to 1.0.22 +- Modify: `/home/rick/code/indiekit-dev/indiekit-cloudron/Dockerfile` — update version +- Modify: `/home/rick/code/indiekit-dev/indiekit-cloudron/indiekit.config.js.template` — add new config options +- Modify: `/home/rick/code/indiekit-dev/indiekit-cloudron/indiekit.config.js.rmendes` — add new config options + +**Key Decisions / Notes:** +- New config options to add: `authorizedFetch`, `parallelWorkers`, `actorType` +- Register new collections `ap_featured` and `ap_featured_tags` (automatic via plugin init) +- Bump CACHE_BUST in Dockerfile + +**Definition of Done:** +- [ ] Version bumped to 1.0.22 +- [ ] Dockerfile references @1.0.22 +- [ ] Config templates updated with new options +- [ ] CACHE_BUST incremented + +**Verify:** +- `jq .version package.json` — returns "1.0.22" +- `grep "1.0.22" /home/rick/code/indiekit-dev/indiekit-cloudron/Dockerfile` — found + +--- + +## Testing Strategy + +- **Shell tests:** Extend existing `activitypub-tests/` suite with 6 new tests (Task 16) +- **Integration testing:** Deploy to Cloudron and verify: + - All existing 12 tests still pass + - New 6 tests pass + - `fedify lookup` resolves actor, instance actor, and post objects + - Posts syndicated after deploy appear on fediverse (Mastodon search) +- **Manual verification:** + - Admin UI pages render for featured posts, featured tags + - Pin/unpin a post and verify it appears in featured collection + - Add/remove a tag and verify featured tags collection + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Fedify API version incompatibility (features require newer Fedify) | Low | High | Check Fedify version in package.json (^1.10.0) — most features available since 0.7-1.0. ParallelMessageQueue since 1.0. Permanent failure handler since 2.0 — verify this is available | +| Breaking existing federation by changing actor properties | Medium | High | Test with `fedify lookup` before and after. Ensure `publicKey` and `assertionMethods` unchanged. Instance actor uses separate identifier | +| MongoDB index conflicts with existing collections | Low | Medium | Use `createIndex` with `background: true` and catch errors | +| Redis queue wrapper breaking delivery | Low | High | Only wrap when `parallelWorkers > 1` and Redis is configured. Fallback to unwrapped queue if ParallelMessageQueue import fails | +| Authorized fetch blocking legitimate unsigned requests | Medium | Medium | Default to `authorizedFetch: false`. Only enable when explicitly configured. Instance actor always allows unsigned access | + +## Open Questions + +- Is `setOutboxPermanentFailureHandler` available in Fedify ^1.10.0? The docs say "since 2.0.0" — if the installed version is <2.0, we'll need to skip this feature or use `onOutboxError` as a fallback. +- The `ParallelMessageQueue` docs say "since 1.0.0" which should be fine with ^1.10.0. + +### Deferred Ideas + +- Custom collections API for other plugins to register arbitrary collections +- Relay support (FEP-ae0c) for large-scale content distribution +- Key rotation admin UI with grace period +- Activity transformers for custom pre-send validation +- Separate inbox/outbox queue configuration for different processing priorities diff --git a/docs/plans/fedify-migration.md b/docs/plans/fedify-migration.md new file mode 100644 index 0000000..638431c --- /dev/null +++ b/docs/plans/fedify-migration.md @@ -0,0 +1,540 @@ +# Fedify Migration Plan — Full Adoption + +**Status:** DRAFT +**Plugin:** `@rmdes/indiekit-endpoint-activitypub` +**Current version:** 0.1.10 +**Target version:** 0.2.0 + +## Executive Summary + +The plugin currently declares `@fedify/fedify` and `@fedify/express` as dependencies but uses **none** of their APIs. All federation logic is hand-rolled using Node's `crypto` module. This plan migrates to proper Fedify adoption, replacing ~400 lines of manual cryptography, signature handling, and protocol plumbing with Fedify's battle-tested implementation. + +## Why Migrate + +| Concern | Current (hand-rolled) | After (Fedify) | +|---------|----------------------|----------------| +| **HTTP Signatures** | Manual `createSign`/`createVerify` — only Draft Cavage | Automatic — Draft Cavage + RFC 9421 + LD Signatures + Object Integrity Proofs | +| **Signature verification** | Only RSA-SHA256, no caching | Multi-algorithm, key caching, origin security (FEP-fe34) | +| **Key management** | Manual RSA-2048 in MongoDB | Automatic RSA + Ed25519 key pairs via `setKeyPairsDispatcher()` | +| **Activity delivery** | Manual `fetch()` to each inbox, no retry | Queue-based with retry, fan-out, shared inbox optimization | +| **WebFinger** | Manual JRD builder | Automatic from actor dispatcher + customizable links | +| **Content negotiation** | Manual `Accept` header check | Automatic via `federation.fetch()` | +| **Actor document** | Manual JSON builder | Type-safe `Person` object via `@fedify/vocab` | +| **Collection pagination** | None (dumps all items) | Cursor-based pagination via collection dispatchers | +| **Remote actor fetching** | Manual `fetch()` with no caching | `ctx.lookupObject()` with document loader caching | +| **NodeInfo** | Not implemented | Automatic via `setNodeInfoDispatcher()` | +| **Error handling** | Manual try/catch per route | Unified error handling via Fedify middleware | + +## Architecture Decision: URL Structure + +### The Problem + +Currently the actor URL is `https://rmendes.net/` (the site root). Fedify requires `{identifier}` in URI templates (e.g., `/users/{identifier}`). Changing the actor URL breaks existing federation relationships because remote servers cache the actor ID. + +### Recommended Approach: New URLs + Migration + +Use conventional fediverse URL patterns under the plugin's mount path: + +| Endpoint | Current URL | New URL | +|----------|-------------|---------| +| Actor | `https://rmendes.net/` | `https://rmendes.net/activitypub/users/{handle}` | +| Inbox | `/activitypub/inbox` | `/activitypub/users/{handle}/inbox` | +| Shared inbox | *(none)* | `/activitypub/inbox` | +| Outbox | `/activitypub/outbox` | `/activitypub/users/{handle}/outbox` | +| Followers | `/activitypub/followers` | `/activitypub/users/{handle}/followers` | +| Following | `/activitypub/following` | `/activitypub/users/{handle}/following` | + +**Migration path:** +1. Set `alsoKnownAs: ["https://rmendes.net/"]` on the new actor +2. Keep content negotiation at `/` returning the actor document (redirects to canonical URL) +3. Keep old `/activitypub/inbox` accepting activities (301 to new path) +4. Send `Move` activity from old URL to new URL (triggers follower re-follow on Mastodon) + +**Backward compatibility:** +- WebFinger returns the new canonical actor URL +- Content negotiation at root still serves the actor document for cached references +- Old inbox endpoint still accepts activities during transition +- `alsoKnownAs` tells remote servers these are the same identity + +### Alternative: Keep Root URL (Fallback) + +If URL migration is too risky, we can use Fedify for crypto/delivery/verification only, keeping manual actor document serving. This gives 70% of the benefits without URL changes. Discussed in Phase 1 notes. + +## Architecture Decision: Express Integration + +Since Indiekit plugins cannot inject app-level middleware (plugins only get route-level mounting), we **cannot** use `integrateFederation()` from `@fedify/express` directly. + +**Approach:** Create the `Federation` object with `createFederation()`, configure all dispatchers and listeners, then call `federation.fetch()` inside Express route handlers. This is the recommended Fedify pattern for custom framework integrations. + +``` +Express Request → convert to standard Request → federation.fetch() → convert Response back +``` + +The `@fedify/express` package's `integrateFederation()` does exactly this internally, so we're not losing anything — just doing the conversion manually in each route handler. + +## Architecture Decision: KvStore Adapter + +Fedify requires a `KvStore` for key storage, follower data, and caching. We already use MongoDB. We need a thin adapter: + +```js +class MongoKvStore { + constructor(collection) { this.collection = collection; } + async get(key) { ... } + async set(key, value) { ... } + async delete(key) { ... } +} +``` + +This reuses the existing MongoDB connection Indiekit already manages. + +## Architecture Decision: Profile Management + +Fedify's actor document is built from the dispatcher callback. Profile data (name, bio, avatar, links) needs to be: +1. **Stored** in a MongoDB collection (`ap_profile`) +2. **Editable** via an admin UI page at `/activitypub/admin/profile` +3. **Read** by the actor dispatcher to build the `Person` object + +Profile fields map to Fedify's `Person` type: + +| UI Field | MongoDB field | Fedify `Person` property | Notes | +|----------|---------------|--------------------------|-------| +| Display name | `name` | `name` | Plain text | +| Bio | `summary` | `summary` | HTML string | +| Avatar | `icon` | `icon` → `Image({ url, mediaType })` | URL or uploaded file | +| Header image | `image` | `image` → `Image({ url, mediaType })` | URL or uploaded file | +| Profile links | `attachments` | `attachments` → `PropertyValue[]` | Key-value pairs (like Mastodon custom fields) | +| Website URL | `url` | `url` | Typically the publication URL | +| Account migration | `alsoKnownAs` | `alsoKnownAs` | Array of previous URLs | + +--- + +## Implementation Phases + +### Phase 1 — Fedify Foundation (non-breaking) + +**Goal:** Wire Fedify into the plugin without changing any external-facing URLs or behavior. Replace internal crypto with Fedify's signing/verification. This is the "safe" phase. + +#### Task 1.1: Create MongoDB KvStore adapter + +**File:** `lib/kv-store.js` (new) + +Implement Fedify's `KvStore` interface backed by MongoDB. The adapter uses the existing `ap_keys` collection (or a new `ap_kv` collection) to store key-value pairs. + +Methods: `get(key)`, `set(key, value)`, `delete(key)`. Keys are arrays of strings — serialize as a joined path (e.g., `["keypair", "rsa", "rick"]` → `"keypair/rsa/rick"`). + +#### Task 1.2: Create Federation instance + +**File:** `lib/federation-setup.js` (new) + +Create the core `Federation` object using `createFederation()`: + +```js +import { createFederation } from "@fedify/fedify"; +import { MongoKvStore } from "./kv-store.js"; + +export function setupFederation(options) { + const { kvCollection, publicationUrl, handle } = options; + + const federation = createFederation({ + kv: new MongoKvStore(kvCollection), + // No queue for now — use InProcessMessageQueue later + }); + + // Configure dispatchers (Tasks 1.3-1.6) + + return federation; +} +``` + +#### Task 1.3: Set up actor dispatcher + +Replace `lib/actor.js` (manual JSON builder) with Fedify's `setActorDispatcher()`. + +```js +import { Person, Image, PropertyValue, Endpoints } from "@fedify/vocab"; + +federation.setActorDispatcher( + "/activitypub/users/{identifier}", + async (ctx, identifier) => { + const profile = await getProfile(collections); // from MongoDB + const keyPairs = await ctx.getActorKeyPairs(identifier); + + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: profile.name || identifier, + summary: profile.summary || "", + url: new URL(publicationUrl), + inbox: ctx.getInboxUri(identifier), + outbox: ctx.getOutboxUri(identifier), + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + publicKey: keyPairs[0]?.cryptographicKey, + assertionMethod: keyPairs[0]?.multikey, + icon: profile.icon ? new Image({ url: new URL(profile.icon) }) : null, + image: profile.image ? new Image({ url: new URL(profile.image) }) : null, + published: profile.createdAt ? Temporal.Instant.from(profile.createdAt) : null, + // alsoKnownAs for migration + alsoKnownAs: profile.alsoKnownAs?.map(u => new URL(u)) || [], + }); + } +); +``` + +#### Task 1.4: Set up key pairs dispatcher + +Replace `lib/keys.js` (manual RSA generation) with Fedify's `setKeyPairsDispatcher()`. + +Fedify generates both RSA (for HTTP Signatures) and Ed25519 (for Object Integrity Proofs) key pairs automatically. Keys are stored in the KvStore. + +**Migration concern:** Existing RSA keys in `ap_keys` collection must be preserved. On first run, import the existing key pair into Fedify's KvStore format so signatures remain valid for remote servers that cached the old public key. + +```js +federation + .setActorDispatcher(...) + .setKeyPairsDispatcher(async (ctx, identifier) => { + // Return existing keys from MongoDB, or let Fedify generate new ones + return []; // Fedify auto-generates if empty + }); +``` + +**Key migration strategy:** +- Read existing RSA key from `ap_keys` collection +- Import it as the first key pair returned by the dispatcher +- Fedify will also generate an Ed25519 key for Object Integrity Proofs +- After migration, the RSA key ID stays `{actorUrl}#main-key` + +#### Task 1.5: Set up inbox listeners + +Replace `lib/inbox.js` (manual switch dispatch) with Fedify's typed inbox listeners: + +```js +import { Follow, Undo, Like, Announce, Create, Delete, Move, Accept } from "@fedify/vocab"; + +federation + .setInboxListeners("/activitypub/users/{identifier}/inbox", "/activitypub/inbox") + .on(Follow, async (ctx, follow) => { + // Auto-accept: send Accept back + // Store follower in MongoDB + const follower = await follow.getActor(); + // ... upsert to ap_followers + await ctx.sendActivity( + { identifier: handle }, + follower, + new Accept({ actor: ctx.getActorUri(handle), object: follow }), + ); + }) + .on(Undo, async (ctx, undo) => { + const inner = await undo.getObject(); + if (inner instanceof Follow) { + // Remove follower + } + // ... handle other Undo types + }) + .on(Like, async (ctx, like) => { /* log activity */ }) + .on(Announce, async (ctx, announce) => { /* log activity */ }) + .on(Create, async (ctx, create) => { /* handle replies */ }) + .on(Delete, async (ctx, del) => { /* clean up */ }) + .on(Move, async (ctx, move) => { /* handle migration */ }); +``` + +**Key benefit:** Fedify automatically verifies HTTP Signatures, LD Signatures, and Object Integrity Proofs on all incoming activities. No more manual `verifyHttpSignature()`. + +#### Task 1.6: Set up collection dispatchers + +Replace manual collection endpoints with Fedify's cursor-based pagination: + +```js +federation.setFollowersDispatcher( + "/activitypub/users/{identifier}/followers", + async (ctx, identifier, cursor) => { + const pageSize = 20; + const skip = cursor ? parseInt(cursor) : 0; + const docs = await collections.ap_followers + .find().sort({ followedAt: -1 }).skip(skip).limit(pageSize).toArray(); + const total = await collections.ap_followers.countDocuments(); + + return { + items: docs.map(f => new URL(f.actorUrl)), + nextCursor: skip + pageSize < total ? String(skip + pageSize) : null, + }; + } +).setCounter(async (ctx, identifier) => { + return await collections.ap_followers.countDocuments(); +}); + +// Same pattern for following, outbox +``` + +#### Task 1.7: Replace outbound delivery with `ctx.sendActivity()` + +Replace `sendSignedActivity()` (manual fetch + HTTP Signatures) with Fedify's queue-based delivery: + +```js +// In syndicator.syndicate(): +await ctx.sendActivity( + { identifier: handle }, + "followers", // special keyword: deliver to all followers + activity, +); +``` + +Fedify handles: +- HTTP Signature signing (Draft Cavage + RFC 9421) +- Linked Data Signatures +- Object Integrity Proofs +- Shared inbox optimization +- Retry on failure +- Rate limiting + +#### Task 1.8: Wire federation.fetch() into Express routes + +Update `index.js` to delegate to `federation.fetch()`: + +```js +// In routesPublic: +router.all("/*", async (request, response, next) => { + // Convert Express request to standard Request + const url = new URL(request.originalUrl, `${request.protocol}://${request.get("host")}`); + const standardRequest = new Request(url, { + method: request.method, + headers: request.headers, + body: ["GET", "HEAD"].includes(request.method) ? undefined : request.body, + }); + + const fedResponse = await federation.fetch(standardRequest, { + contextData: { collections, publicationUrl }, + }); + + if (fedResponse.status === 404) { + return next(); // Fedify didn't handle it, pass to next middleware + } + + // Convert Response back to Express + response.status(fedResponse.status); + for (const [key, value] of fedResponse.headers) { + response.set(key, value); + } + const body = await fedResponse.text(); + response.send(body); +}); +``` + +#### Task 1.9: WebFinger via Fedify + +Remove `lib/webfinger.js`. Fedify handles WebFinger automatically when the actor dispatcher is configured. The `routesWellKnown` handler delegates to `federation.fetch()`: + +```js +get routesWellKnown() { + const router = express.Router(); + router.get("/webfinger", async (req, res, next) => { + // Delegate to federation.fetch() + }); + return router; +} +``` + +#### Task 1.10: NodeInfo support + +Add `setNodeInfoDispatcher()` — this is new functionality the hand-rolled code doesn't have: + +```js +federation.setNodeInfoDispatcher("/nodeinfo/2.1", async (ctx) => ({ + software: { + name: "indiekit", + version: { major: 1, minor: 0, patch: 0 }, + }, + protocols: ["activitypub"], + usage: { + users: { total: 1, activeMonth: 1, activeHalfyear: 1 }, + localPosts: await collections.posts?.countDocuments() || 0, + localComments: 0, + }, +})); +``` + +### Phase 2 — Profile Management UI + +**Goal:** Allow the user to edit their ActivityPub profile from the Indiekit admin backend. + +#### Task 2.1: Profile MongoDB collection + +Add `ap_profile` collection. Store a single document: + +```json +{ + "handle": "rick", + "name": "Ricardo Mendes", + "summary": "

IndieWeb enthusiast

", + "icon": "https://rmendes.net/avatar.jpg", + "image": "https://rmendes.net/header.jpg", + "url": "https://rmendes.net/", + "attachments": [ + { "name": "Website", "value": "rmendes.net" }, + { "name": "GitHub", "value": "rmdes" } + ], + "alsoKnownAs": ["https://mastodon.social/@rick"], + "manuallyApprovesFollowers": false, + "updatedAt": "2025-02-18T00:00:00.000Z" +} +``` + +Initialize from current config options (`options.actor`) on first run. + +#### Task 2.2: Profile controller + +**File:** `lib/controllers/profile.js` (new) + +- `GET /activitypub/admin/profile` — render profile edit form +- `POST /activitypub/admin/profile` — save profile to MongoDB, clear cached actor document + +#### Task 2.3: Profile edit template + +**File:** `views/activitypub-profile.njk` (new) + +Form fields: +- Display name (text input) +- Bio (textarea, HTML allowed) +- Avatar URL (text input, optionally file upload) +- Header image URL (text input, optionally file upload) +- Profile links (repeatable key-value pairs — like Mastodon's custom fields) +- Also Known As (text input for migration URL) +- Manually approves followers (checkbox) + +Use existing Indiekit frontend components (from `@indiekit/frontend`). + +#### Task 2.4: Wire profile into actor dispatcher + +The actor dispatcher reads from the `ap_profile` collection instead of static config options. Profile changes are reflected immediately in the actor document — remote servers fetch fresh copies periodically. + +#### Task 2.5: Send Update activity on profile change + +When the user saves their profile, send an `Update(Person)` activity to all followers so their caches refresh: + +```js +await ctx.sendActivity( + { identifier: handle }, + "followers", + new Update({ + actor: ctx.getActorUri(handle), + object: await buildActorFromProfile(ctx, profile), + }), +); +``` + +### Phase 3 — Cleanup and Polish + +#### Task 3.1: Delete replaced files + +Remove files that are fully replaced by Fedify: +- `lib/federation.js` — replaced by `lib/federation-setup.js` + Fedify +- `lib/actor.js` — replaced by actor dispatcher +- `lib/keys.js` — replaced by key pairs dispatcher +- `lib/webfinger.js` — replaced by Fedify automatic handling + +#### Task 3.2: Keep and adapt + +Files that are kept but adapted: +- `lib/jf2-to-as2.js` — KEEP. Converts Indiekit JF2 → AS2 for the outbox. Adapt to return Fedify `@fedify/vocab` objects instead of plain JSON. +- `lib/inbox.js` — DELETE (replaced by inbox listeners). Business logic moves into listener callbacks in `federation-setup.js`. +- `lib/migration.js` — KEEP. CSV import is independent of Fedify. +- `lib/controllers/*.js` — KEEP. Admin UI controllers are independent. + +#### Task 3.3: Add message queue + +For production reliability, add `InProcessMessageQueue` (or a persistent queue): + +```js +import { InProcessMessageQueue } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MongoKvStore(kvCollection), + queue: new InProcessMessageQueue(), +}); +``` + +This enables background delivery with retry — activities that fail to deliver are retried automatically. + +#### Task 3.4: Add logging + +Configure LogTape for Fedify-specific logging: + +```js +import { configure, getConsoleSink } from "@logtape/logtape"; + +await configure({ + sinks: { console: getConsoleSink() }, + loggers: [ + { category: "fedify", sinks: ["console"], lowestLevel: "info" }, + ], +}); +``` + +#### Task 3.5: Update package.json + +- Remove unused dependencies (if any — `@fedify/fedify` and `@fedify/express` are already declared) +- Add `@fedify/vocab` if not included in `@fedify/fedify` +- Add `@logtape/logtape` for logging +- Bump version to `0.2.0` + +#### Task 3.6: Update admin navigation + +Add "Profile" link to the dashboard navigation items: + +```js +get navigationItems() { + return { + href: this.options.mountPath, + text: "activitypub.title", + requiresDatabase: true, + }; +} +``` + +Add profile card to dashboard showing current avatar, name, bio, follower count. + +--- + +## Files Changed Summary + +| File | Action | Description | +|------|--------|-------------| +| `lib/kv-store.js` | NEW | MongoDB KvStore adapter for Fedify | +| `lib/federation-setup.js` | NEW | Fedify Federation creation + all dispatchers/listeners | +| `lib/controllers/profile.js` | NEW | Profile edit GET/POST controller | +| `views/activitypub-profile.njk` | NEW | Profile edit form template | +| `index.js` | MODIFY | Wire federation.fetch() into routes, add profile route | +| `lib/jf2-to-as2.js` | MODIFY | Return @fedify/vocab objects instead of plain JSON | +| `lib/federation.js` | DELETE | Replaced by federation-setup.js + Fedify | +| `lib/actor.js` | DELETE | Replaced by actor dispatcher | +| `lib/keys.js` | DELETE | Replaced by key pairs dispatcher | +| `lib/webfinger.js` | DELETE | Replaced by Fedify automatic handling | +| `lib/inbox.js` | DELETE | Logic moved to inbox listeners | +| `package.json` | MODIFY | Add @logtape/logtape, bump version | +| `locales/en.json` | MODIFY | Add profile-related i18n strings | + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Existing followers can't verify signatures after key migration | Medium | High | Import existing RSA key into Fedify KvStore format first | +| Actor URL change breaks federation | High (if we change URLs) | High | alsoKnownAs + Move activity + keep old endpoints active | +| Fedify version incompatibility with Node 22 | Low | Medium | Already declared in package.json, should be tested | +| MongoDB KvStore adapter bugs | Medium | Medium | Test with real federation before deploying | +| Express ↔ standard Request conversion issues | Medium | Medium | Test content-type headers, body parsing, signature headers | + +## Migration Checklist (Deploy Day) + +1. [ ] Backup MongoDB (`ap_followers`, `ap_following`, `ap_activities`, `ap_keys`) +2. [ ] Export existing RSA key pair from `ap_keys` +3. [ ] Deploy new version +4. [ ] Verify WebFinger returns correct actor URL +5. [ ] Verify actor document is served correctly +6. [ ] Test receiving a Follow from a test account +7. [ ] Test sending a post to followers +8. [ ] Verify existing followers can still see posts +9. [ ] If URL changed: verify alsoKnownAs is set, send Move activity +10. [ ] Monitor logs for signature verification failures diff --git a/index.js b/index.js index 9503bfe..a39c2bd 100644 --- a/index.js +++ b/index.js @@ -21,6 +21,16 @@ import { profileGetController, profilePostController, } from "./lib/controllers/profile.js"; +import { + featuredGetController, + featuredPinController, + featuredUnpinController, +} from "./lib/controllers/featured.js"; +import { + featuredTagsGetController, + featuredTagsAddController, + featuredTagsRemoveController, +} from "./lib/controllers/featured-tags.js"; import { refollowPauseController, refollowResumeController, @@ -42,6 +52,8 @@ const defaults = { activityRetentionDays: 90, storeRawActivities: false, redisUrl: "", + parallelWorkers: 5, + actorType: "Person", }; export default class ActivityPubEndpoint { @@ -136,6 +148,12 @@ export default class ActivityPubEndpoint { router.get("/admin/followers", followersController(mp)); router.get("/admin/following", followingController(mp)); router.get("/admin/activities", activitiesController(mp)); + router.get("/admin/featured", featuredGetController(mp)); + router.post("/admin/featured/pin", featuredPinController()); + router.post("/admin/featured/unpin", featuredUnpinController()); + router.get("/admin/tags", featuredTagsGetController(mp)); + router.post("/admin/tags/add", featuredTagsAddController()); + router.post("/admin/tags/remove", featuredTagsRemoveController()); router.get("/admin/profile", profileGetController(mp)); router.post("/admin/profile", profilePostController(mp)); router.get("/admin/migrate", migrateGetController(mp, this.options)); @@ -167,8 +185,11 @@ export default class ActivityPubEndpoint { router.use((req, res, next) => { if (!self._fedifyMiddleware) return next(); if (req.method !== "GET" && req.method !== "HEAD") return next(); - // Skip Fedify for admin routes — handled by authenticated router - if (req.path.startsWith("/admin")) return next(); + // Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1). + // All other paths in this root-mounted router are handled by the + // content negotiation catch-all below. Passing arbitrary paths like + // /notes/... to Fedify causes harmless but noisy 404 warnings. + if (!req.path.startsWith("/nodeinfo/")) return next(); return self._fedifyMiddleware(req, res, next); }); @@ -266,7 +287,7 @@ export default class ActivityPubEndpoint { const ctx = self._federation.createContext( new URL(self._publicationUrl), - {}, + { handle, publicationUrl: self._publicationUrl }, ); // For replies, resolve the original post author for proper @@ -327,11 +348,16 @@ export default class ActivityPubEndpoint { `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`, ); - // Send to followers + // Send to followers via shared inboxes with collection sync (FEP-8fcf) await ctx.sendActivity( { identifier: handle }, "followers", activity, + { + preferSharedInbox: true, + syncCollection: true, + orderingKey: properties.url, + }, ); // For replies, also deliver to the original post author's inbox @@ -342,6 +368,7 @@ export default class ActivityPubEndpoint { { identifier: handle }, replyToActor.recipient, activity, + { orderingKey: properties.url }, ); console.info( `[ActivityPub] Reply delivered to author: ${replyToActor.url}`, @@ -408,7 +435,7 @@ export default class ActivityPubEndpoint { const handle = this.options.actor.handle; const ctx = this._federation.createContext( new URL(this._publicationUrl), - {}, + { handle, publicationUrl: this._publicationUrl }, ); // Resolve the remote actor to get their inbox @@ -423,7 +450,9 @@ export default class ActivityPubEndpoint { object: new URL(actorUrl), }); - await ctx.sendActivity({ identifier: handle }, remoteActor, follow); + await ctx.sendActivity({ identifier: handle }, remoteActor, follow, { + orderingKey: actorUrl, + }); // Store in ap_following const name = @@ -502,7 +531,7 @@ export default class ActivityPubEndpoint { const handle = this.options.actor.handle; const ctx = this._federation.createContext( new URL(this._publicationUrl), - {}, + { handle, publicationUrl: this._publicationUrl }, ); const remoteActor = await ctx.lookupObject(actorUrl); @@ -531,7 +560,9 @@ export default class ActivityPubEndpoint { object: follow, }); - await ctx.sendActivity({ identifier: handle }, remoteActor, undo); + await ctx.sendActivity({ identifier: handle }, remoteActor, undo, { + orderingKey: actorUrl, + }); await this._collections.ap_following.deleteOne({ actorUrl }); console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`); @@ -586,6 +617,8 @@ export default class ActivityPubEndpoint { Indiekit.addCollection("ap_keys"); Indiekit.addCollection("ap_kv"); Indiekit.addCollection("ap_profile"); + Indiekit.addCollection("ap_featured"); + Indiekit.addCollection("ap_featured_tags"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; @@ -596,6 +629,8 @@ export default class ActivityPubEndpoint { ap_keys: indiekitCollections.get("ap_keys"), ap_kv: indiekitCollections.get("ap_kv"), ap_profile: indiekitCollections.get("ap_profile"), + ap_featured: indiekitCollections.get("ap_featured"), + ap_featured_tags: indiekitCollections.get("ap_featured_tags"), get posts() { return indiekitCollections.get("posts"); }, @@ -652,6 +687,9 @@ export default class ActivityPubEndpoint { handle: this.options.actor.handle, storeRawActivities: this.options.storeRawActivities, redisUrl: this.options.redisUrl, + publicationUrl: this._publicationUrl, + parallelWorkers: this.options.parallelWorkers, + actorType: this.options.actorType, }); this._federation = federation; diff --git a/lib/batch-refollow.js b/lib/batch-refollow.js index b56fbae..e273240 100644 --- a/lib/batch-refollow.js +++ b/lib/batch-refollow.js @@ -225,7 +225,7 @@ async function processOneFollow(options, entry) { const { federation, collections, handle, publicationUrl } = options; try { - const ctx = federation.createContext(new URL(publicationUrl), {}); + const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl }); // Resolve the remote actor const remoteActor = await ctx.lookupObject(entry.actorUrl); @@ -242,7 +242,9 @@ async function processOneFollow(options, entry) { object: new URL(canonicalUrl), }); - await ctx.sendActivity({ identifier: handle }, remoteActor, follow); + await ctx.sendActivity({ identifier: handle }, remoteActor, follow, { + orderingKey: canonicalUrl, + }); // Mark as sent — update actorUrl to canonical form so Accept handler // can match when the remote server responds diff --git a/lib/controllers/featured-tags.js b/lib/controllers/featured-tags.js new file mode 100644 index 0000000..e694085 --- /dev/null +++ b/lib/controllers/featured-tags.js @@ -0,0 +1,71 @@ +/** + * Featured tags controller — list, add, and remove featured hashtags. + */ + +export function featuredTagsGetController(mountPath) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collection = application?.collections?.get("ap_featured_tags"); + + const tags = collection + ? await collection.find().sort({ addedAt: -1 }).toArray() + : []; + + response.render("activitypub-featured-tags", { + title: + response.locals.__("activitypub.featuredTags") || "Featured Tags", + tags, + mountPath, + }); + } catch (error) { + next(error); + } + }; +} + +export function featuredTagsAddController() { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collection = application?.collections?.get("ap_featured_tags"); + if (!collection) return response.status(500).send("No collection"); + + let { tag } = request.body; + if (!tag) return response.status(400).send("Missing tag"); + + // Normalize: strip leading # and lowercase + tag = tag.replace(/^#/, "").toLowerCase().trim(); + if (!tag) return response.status(400).send("Invalid tag"); + + await collection.updateOne( + { tag }, + { $set: { tag, addedAt: new Date().toISOString() } }, + { upsert: true }, + ); + + response.redirect("back"); + } catch (error) { + next(error); + } + }; +} + +export function featuredTagsRemoveController() { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collection = application?.collections?.get("ap_featured_tags"); + if (!collection) return response.status(500).send("No collection"); + + const { tag } = request.body; + if (!tag) return response.status(400).send("Missing tag"); + + await collection.deleteOne({ tag }); + + response.redirect("back"); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/featured.js b/lib/controllers/featured.js new file mode 100644 index 0000000..46b32ad --- /dev/null +++ b/lib/controllers/featured.js @@ -0,0 +1,117 @@ +/** + * Featured (pinned) posts controller — list, pin, and unpin posts. + */ +const MAX_PINS = 5; + +export function featuredGetController(mountPath) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const featuredCollection = application?.collections?.get("ap_featured"); + const postsCollection = application?.collections?.get("posts"); + + const pinnedDocs = featuredCollection + ? await featuredCollection.find().sort({ pinnedAt: -1 }).toArray() + : []; + + // Enrich pinned posts with title/type from posts collection + const pinned = []; + for (const doc of pinnedDocs) { + let title = doc.postUrl; + let postType = "note"; + if (postsCollection) { + const post = await postsCollection.findOne({ + "properties.url": doc.postUrl, + }); + if (post?.properties) { + title = + post.properties.name || + post.properties.content?.text?.slice(0, 80) || + doc.postUrl; + postType = post.properties["post-type"] || "note"; + } + } + pinned.push({ ...doc, title, postType }); + } + + // Get recent posts for the "pin" dropdown + const recentPosts = postsCollection + ? await postsCollection + .find() + .sort({ "properties.published": -1 }) + .limit(20) + .toArray() + : []; + + const pinnedUrls = new Set(pinnedDocs.map((d) => d.postUrl)); + const availablePosts = recentPosts + .filter((p) => p.properties?.url && !pinnedUrls.has(p.properties.url)) + .map((p) => ({ + url: p.properties.url, + title: + p.properties.name || + p.properties.content?.text?.slice(0, 80) || + p.properties.url, + postType: p.properties["post-type"] || "note", + })); + + response.render("activitypub-featured", { + title: response.locals.__("activitypub.featured") || "Pinned Posts", + pinned, + availablePosts, + maxPins: MAX_PINS, + canPin: pinned.length < MAX_PINS, + mountPath, + }); + } catch (error) { + next(error); + } + }; +} + +export function featuredPinController() { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collection = application?.collections?.get("ap_featured"); + if (!collection) return response.status(500).send("No collection"); + + const { postUrl } = request.body; + if (!postUrl) return response.status(400).send("Missing postUrl"); + + const count = await collection.countDocuments(); + if (count >= MAX_PINS) { + return response.status(400).send("Maximum pins reached"); + } + + await collection.updateOne( + { postUrl }, + { $set: { postUrl, pinnedAt: new Date().toISOString() } }, + { upsert: true }, + ); + + response.redirect("back"); + } catch (error) { + next(error); + } + }; +} + +export function featuredUnpinController() { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collection = application?.collections?.get("ap_featured"); + if (!collection) return response.status(500).send("No collection"); + + const { postUrl } = request.body; + if (!postUrl) return response.status(400).send("Missing postUrl"); + + await collection.deleteOne({ postUrl }); + + response.redirect("back"); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/profile.js b/lib/controllers/profile.js index c08b68c..8a5062d 100644 --- a/lib/controllers/profile.js +++ b/lib/controllers/profile.js @@ -36,8 +36,15 @@ export function profilePostController(mountPath) { return next(new Error("ap_profile collection not available")); } - const { name, summary, url, icon, image, manuallyApprovesFollowers } = - request.body; + const { + name, + summary, + url, + icon, + image, + manuallyApprovesFollowers, + authorizedFetch, + } = request.body; const update = { $set: { @@ -47,6 +54,7 @@ export function profilePostController(mountPath) { icon: icon?.trim() || "", image: image?.trim() || "", manuallyApprovesFollowers: manuallyApprovesFollowers === "true", + authorizedFetch: authorizedFetch === "true", updatedAt: new Date().toISOString(), }, }; diff --git a/lib/federation-bridge.js b/lib/federation-bridge.js index 26c17c6..3ef4384 100644 --- a/lib/federation-bridge.js +++ b/lib/federation-bridge.js @@ -44,8 +44,9 @@ export function fromExpressRequest(req) { * * @param {import("express").Response} res - Express response * @param {Response} response - Standard Response from federation.fetch() + * @param {Request} [request] - Original request (for targeted patching) */ -async function sendFedifyResponse(res, response) { +async function sendFedifyResponse(res, response, request) { res.status(response.status); response.headers.forEach((value, key) => { res.setHeader(key, value); @@ -56,6 +57,33 @@ async function sendFedifyResponse(res, response) { return; } + // WORKAROUND: Fedify serializes endpoints with "type": "as:Endpoints" + // which is not a real ActivityStreams type (fails browser.pub validation). + // For actor JSON responses, buffer the body and strip the invalid type. + // See: https://github.com/fedify-dev/fedify/issues/576 + // TODO: Remove this workaround when Fedify fixes the upstream issue. + const contentType = response.headers.get("content-type") || ""; + const isActorJson = + contentType.includes("activity+json") || + contentType.includes("ld+json"); + + if (isActorJson) { + const body = await response.text(); + try { + const json = JSON.parse(body); + if (json.endpoints?.type) { + delete json.endpoints.type; + } + const patched = JSON.stringify(json); + res.setHeader("content-length", Buffer.byteLength(patched)); + res.end(patched); + } catch { + // Not valid JSON — send as-is + res.end(body); + } + return; + } + const reader = response.body.getReader(); await new Promise((resolve) => { function read({ done, value }) { diff --git a/lib/federation-setup.js b/lib/federation-setup.js index 11221c3..9158b49 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -7,13 +7,23 @@ */ import { AsyncLocalStorage } from "node:async_hooks"; +import { createRequire } from "node:module"; import { Temporal } from "@js-temporal/polyfill"; import { + Application, + Article, + Create, Endpoints, + Group, + Hashtag, Image, InProcessMessageQueue, + Note, + Organization, + ParallelMessageQueue, Person, PropertyValue, + Service, createFederation, exportJwk, generateCryptoKeyPair, @@ -25,6 +35,7 @@ import { RedisMessageQueue } from "@fedify/redis"; import Redis from "ioredis"; import { MongoKvStore } from "./kv-store.js"; import { registerInboxListeners } from "./inbox-listeners.js"; +import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js"; /** * Create and configure a Fedify Federation instance. @@ -46,8 +57,15 @@ export function setupFederation(options) { handle, storeRawActivities = false, redisUrl = "", + publicationUrl = "", + parallelWorkers = 5, + actorType = "Person", } = options; + // Map config string to Fedify actor class + const actorTypeMap = { Person, Service, Application, Organization, Group }; + const ActorClass = actorTypeMap[actorType] || Person; + // Configure LogTape for Fedify delivery logging (once per process) if (!_logtapeConfigured) { _logtapeConfigured = true; @@ -71,8 +89,16 @@ export function setupFederation(options) { let queue; if (redisUrl) { - queue = new RedisMessageQueue(() => new Redis(redisUrl)); - console.info("[ActivityPub] Using Redis message queue"); + const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl)); + if (parallelWorkers > 1) { + queue = new ParallelMessageQueue(redisQueue, parallelWorkers); + console.info( + `[ActivityPub] Using Redis message queue with ${parallelWorkers} parallel workers`, + ); + } else { + queue = redisQueue; + console.info("[ActivityPub] Using Redis message queue (single worker)"); + } } else { queue = new InProcessMessageQueue(); console.warn( @@ -90,6 +116,26 @@ export function setupFederation(options) { .setActorDispatcher( `${mountPath}/users/{identifier}`, async (ctx, identifier) => { + // Instance actor: Application-type actor for the domain itself + // Required for authorized fetch to avoid infinite loops + const hostname = ctx.url?.hostname || ""; + if (identifier === hostname) { + const keyPairs = await ctx.getActorKeyPairs(identifier); + const appOptions = { + id: ctx.getActorUri(identifier), + preferredUsername: hostname, + name: hostname, + inbox: ctx.getInboxUri(identifier), + outbox: ctx.getOutboxUri(identifier), + endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), + }; + if (keyPairs.length > 0) { + appOptions.publicKey = keyPairs[0].cryptographicKey; + appOptions.assertionMethods = keyPairs.map((k) => k.multikey); + } + return new Application(appOptions); + } + if (identifier !== handle) return null; const profile = await getProfile(collections); @@ -104,6 +150,9 @@ export function setupFederation(options) { outbox: ctx.getOutboxUri(identifier), followers: ctx.getFollowersUri(identifier), following: ctx.getFollowingUri(identifier), + liked: ctx.getLikedUri(identifier), + featured: ctx.getFeaturedUri(identifier), + featuredTags: ctx.getFeaturedTagsUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false, @@ -148,12 +197,36 @@ export function setupFederation(options) { personOptions.published = Temporal.Instant.from(profile.createdAt); } - return new Person(personOptions); + return new ActorClass(personOptions); }, ) - .mapHandle((_ctx, username) => (username === handle ? handle : null)) + .mapHandle((_ctx, username) => { + if (username === handle) return handle; + // Accept hostname as valid identifier for instance actor + if (publicationUrl) { + try { + const hostname = new URL(publicationUrl).hostname; + if (username === hostname) return hostname; + } catch { /* ignore */ } + } + return null; + }) + .mapAlias((_ctx, alias) => { + // Resolve profile URL and /@handle patterns via WebFinger. + // Must return { identifier } or { username }, not a bare string. + if (!publicationUrl) return null; + try { + const pub = new URL(publicationUrl); + if (alias.hostname !== pub.hostname) return null; + const path = alias.pathname.replace(/\/$/, ""); + if (path === "" || path === `/@${handle}`) return { identifier: handle }; + } catch { /* ignore */ } + return null; + }) .setKeyPairsDispatcher(async (ctx, identifier) => { - if (identifier !== handle) return []; + // Allow key pairs for both the main actor and instance actor + const hostname = ctx.url?.hostname || ""; + if (identifier !== handle && identifier !== hostname) return []; const keyPairs = []; @@ -225,6 +298,16 @@ export function setupFederation(options) { return keyPairs; }); + // NOTE: .authorize() is intentionally NOT chained here. + // Fedify's authorize predicate triggers HTTP Signature verification on + // every GET to the actor endpoint. When a remote server that requires + // authorized fetch (e.g. kobolds.online, void.ello.tech) requests our + // actor, Fedify tries to fetch THEIR public key to verify the signature. + // Those instances return 401, causing a FetchError that Fedify doesn't + // catch — resulting in 500s for those servers and error log spam. + // Authorized fetch requires authenticated document loading (using the + // instance actor's keys for outgoing fetches), which Fedify doesn't yet + // support out of the box. Re-enable once Fedify adds this capability. // --- Inbox listeners --- const inboxChain = federation.setInboxListeners( @@ -248,8 +331,22 @@ export function setupFederation(options) { setupFollowers(federation, mountPath, handle, collections); setupFollowing(federation, mountPath, handle, collections); setupOutbox(federation, mountPath, handle, collections); + setupLiked(federation, mountPath, handle, collections); + setupFeatured(federation, mountPath, handle, collections, publicationUrl); + setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl); + + // --- Object dispatchers (make posts dereferenceable) --- + setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl); // --- NodeInfo --- + let softwareVersion = { major: 1, minor: 0, patch: 0 }; + try { + const require = createRequire(import.meta.url); + const pkg = require("@indiekit/indiekit/package.json"); + const [major, minor, patch] = pkg.version.split(/[.-]/).map(Number); + if (!Number.isNaN(major)) softwareVersion = { major, minor: minor || 0, patch: patch || 0 }; + } catch { /* fallback to 1.0.0 */ } + federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => { const postsCount = collections.posts ? await collections.posts.countDocuments() @@ -258,7 +355,7 @@ export function setupFederation(options) { return { software: { name: "indiekit", - version: { major: 1, minor: 0, patch: 0 }, + version: softwareVersion, }, protocols: ["activitypub"], usage: { @@ -288,8 +385,29 @@ function setupFollowers(federation, mountPath, handle, collections) { `${mountPath}/users/{identifier}/followers`, async (ctx, identifier, cursor) => { if (identifier !== handle) return null; + + // One-shot collection: when cursor is null, return ALL followers + // as Recipient objects so sendActivity("followers") can deliver. + // See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients + if (cursor == null) { + const docs = await collections.ap_followers + .find() + .sort({ followedAt: -1 }) + .toArray(); + return { + items: docs.map((f) => ({ + id: new URL(f.actorUrl), + inboxId: f.inbox ? new URL(f.inbox) : null, + endpoints: f.sharedInbox + ? { sharedInbox: new URL(f.sharedInbox) } + : null, + })), + }; + } + + // Paginated collection: for remote browsing of /followers endpoint const pageSize = 20; - const skip = cursor ? Number.parseInt(cursor, 10) : 0; + const skip = Number.parseInt(cursor, 10); const docs = await collections.ap_followers .find() .sort({ followedAt: -1 }) @@ -342,6 +460,118 @@ function setupFollowing(federation, mountPath, handle, collections) { .setFirstCursor(async () => "0"); } +function setupLiked(federation, mountPath, handle, collections) { + federation + .setLikedDispatcher( + `${mountPath}/users/{identifier}/liked`, + async (ctx, identifier, cursor) => { + if (identifier !== handle) return null; + if (!collections.posts) return { items: [] }; + + const pageSize = 20; + const skip = cursor ? Number.parseInt(cursor, 10) : 0; + const query = { "properties.post-type": "like" }; + const docs = await collections.posts + .find(query) + .sort({ "properties.published": -1 }) + .skip(skip) + .limit(pageSize) + .toArray(); + const total = await collections.posts.countDocuments(query); + + const items = docs + .map((d) => { + const likeOf = d.properties?.["like-of"]; + return likeOf ? new URL(likeOf) : null; + }) + .filter(Boolean); + + return { + items, + nextCursor: + skip + pageSize < total ? String(skip + pageSize) : null, + }; + }, + ) + .setCounter(async (ctx, identifier) => { + if (identifier !== handle) return 0; + if (!collections.posts) return 0; + return await collections.posts.countDocuments({ + "properties.post-type": "like", + }); + }) + .setFirstCursor(async () => "0"); +} + +function setupFeatured(federation, mountPath, handle, collections, publicationUrl) { + federation.setFeaturedDispatcher( + `${mountPath}/users/{identifier}/featured`, + async (ctx, identifier) => { + if (identifier !== handle) return null; + if (!collections.ap_featured) return { items: [] }; + + const docs = await collections.ap_featured + .find() + .sort({ pinnedAt: -1 }) + .toArray(); + + // Convert pinned post URLs to Fedify Note/Article objects + const items = []; + for (const doc of docs) { + if (!collections.posts) continue; + const post = await collections.posts.findOne({ + "properties.url": doc.postUrl, + }); + if (!post) continue; + const actorUrl = ctx.getActorUri(identifier).href; + const activity = jf2ToAS2Activity( + post.properties, + actorUrl, + publicationUrl, + ); + if (activity instanceof Create) { + const obj = await activity.getObject(); + if (obj) items.push(obj); + } + } + + return { items }; + }, + ); +} + +function setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl) { + federation.setFeaturedTagsDispatcher( + `${mountPath}/users/{identifier}/tags`, + async (ctx, identifier) => { + if (identifier !== handle) return null; + if (!collections.ap_featured_tags) return { items: [] }; + + const docs = await collections.ap_featured_tags + .find() + .sort({ addedAt: -1 }) + .toArray(); + + const baseUrl = publicationUrl + ? publicationUrl.replace(/\/$/, "") + : ctx.url.origin; + + const items = docs.map( + (doc) => + new Hashtag({ + name: `#${doc.tag}`, + href: new URL( + `/categories/${encodeURIComponent(doc.tag)}`, + baseUrl, + ), + }), + ); + + return { items }; + }, + ); +} + function setupOutbox(federation, mountPath, handle, collections) { federation .setOutboxDispatcher( @@ -394,6 +624,41 @@ function setupOutbox(federation, mountPath, handle, collections) { .setFirstCursor(async () => "0"); } +function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) { + // Shared lookup: find post by URL path, convert to Fedify Note/Article + async function resolvePost(ctx, id) { + if (!collections.posts || !publicationUrl) return null; + const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`; + const post = await collections.posts.findOne({ "properties.url": postUrl }); + if (!post) return null; + const actorUrl = ctx.getActorUri(handle).href; + const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl); + // Only Create activities wrap Note/Article objects + if (!(activity instanceof Create)) return null; + return await activity.getObject(); + } + + // Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin + federation.setObjectDispatcher( + Note, + `${mountPath}/objects/note/{+id}`, + async (ctx, { id }) => { + const obj = await resolvePost(ctx, id); + return obj instanceof Note ? obj : null; + }, + ); + + // Article dispatcher + federation.setObjectDispatcher( + Article, + `${mountPath}/objects/article/{+id}`, + async (ctx, { id }) => { + const obj = await resolvePost(ctx, id); + return obj instanceof Article ? obj : null; + }, + ); +} + // --- Helpers --- async function getProfile(collections) { diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 36e7f11..c2b6431 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -73,6 +73,7 @@ export function registerInboxListeners(inboxChain, options) { actor: ctx.getActorUri(handle), object: follow, }), + { orderingKey: followerUrl }, ); await logActivity(collections, storeRawActivities, { diff --git a/locales/en.json b/locales/en.json index a2bdc58..c6e81e5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4,6 +4,8 @@ "followers": "Followers", "following": "Following", "activities": "Activity log", + "featured": "Pinned Posts", + "featuredTags": "Featured Tags", "recentActivity": "Recent activity", "noActivity": "No activity yet. Once your actor is federated, interactions will appear here.", "noFollowers": "No followers yet.", @@ -36,6 +38,8 @@ "imageHint": "URL to a banner image shown at the top of your profile", "manualApprovalLabel": "Manually approve followers", "manualApprovalHint": "When enabled, follow requests require your approval before they take effect", + "authorizedFetchLabel": "Require authorized fetch (secure mode)", + "authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.", "save": "Save profile", "saved": "Profile saved. Changes are now visible to the fediverse." }, diff --git a/package-lock.json b/package-lock.json index 9ac2646..10997c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.0.20", + "version": "1.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.0.20", + "version": "1.0.21", "license": "MIT", "dependencies": { - "@fedify/express": "^1.9.0", - "@fedify/fedify": "^1.10.0", + "@fedify/express": "^1.10.3", + "@fedify/fedify": "^1.10.3", "@fedify/redis": "^1.10.3", "@js-temporal/polyfill": "^0.5.0", "express": "^5.0.0", diff --git a/package.json b/package.json index bc4268c..1579ad9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.0.21", + "version": "1.0.26", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", @@ -37,8 +37,8 @@ "url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues" }, "dependencies": { - "@fedify/express": "^1.9.0", - "@fedify/fedify": "^1.10.0", + "@fedify/express": "^1.10.3", + "@fedify/fedify": "^1.10.3", "@fedify/redis": "^1.10.3", "@js-temporal/polyfill": "^0.5.0", "express": "^5.0.0", diff --git a/views/activitypub-featured-tags.njk b/views/activitypub-featured-tags.njk new file mode 100644 index 0000000..2867c98 --- /dev/null +++ b/views/activitypub-featured-tags.njk @@ -0,0 +1,43 @@ +{% extends "document.njk" %} + +{% from "heading/macro.njk" import heading with context %} +{% from "prose/macro.njk" import prose with context %} + +{% block content %} + {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} + + {% if tags.length > 0 %} + + + + + + + + + + {% for item in tags %} + + + + + + {% endfor %} + +
TagAdded
#{{ item.tag }}{% if item.addedAt %}{{ item.addedAt | date("PPp") }}{% endif %} +
+ + +
+
+ {% else %} + {{ prose({ text: "No featured tags yet. Add a hashtag to help others discover your content." }) }} + {% endif %} + +

Add a featured tag

+
+ + + +
+{% endblock %} diff --git a/views/activitypub-featured.njk b/views/activitypub-featured.njk new file mode 100644 index 0000000..a841847 --- /dev/null +++ b/views/activitypub-featured.njk @@ -0,0 +1,52 @@ +{% extends "document.njk" %} + +{% from "heading/macro.njk" import heading with context %} +{% from "prose/macro.njk" import prose with context %} + +{% block content %} + {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} + + {% if pinned.length > 0 %} + + + + + + + + + + + {% for item in pinned %} + + + + + + + {% endfor %} + +
PostTypePinned
{{ item.title }}{{ item.postType }}{% if item.pinnedAt %}{{ item.pinnedAt | date("PPp") }}{% endif %} +
+ + +
+
+ {% else %} + {{ prose({ text: "No pinned posts yet." }) }} + {% endif %} + + {% if canPin and availablePosts.length > 0 %} +

Pin a post ({{ pinned.length }}/{{ maxPins }})

+
+ + +
+ {% elif not canPin %} + {{ prose({ text: "Maximum of " + maxPins + " pinned posts reached. Unpin one to add another." }) }} + {% endif %} +{% endblock %} diff --git a/views/activitypub-profile.njk b/views/activitypub-profile.njk index 58e0c26..223ffd9 100644 --- a/views/activitypub-profile.njk +++ b/views/activitypub-profile.njk @@ -69,6 +69,18 @@ values: ["true"] if profile.manuallyApprovesFollowers else [] }) }} + {{ checkboxes({ + name: "authorizedFetch", + items: [ + { + label: __("activitypub.profile.authorizedFetchLabel"), + value: "true", + hint: __("activitypub.profile.authorizedFetchHint") + } + ], + values: ["true"] if profile.authorizedFetch else [] + }) }} + {{ button({ text: __("activitypub.profile.save") }) }} {% endblock %}