mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
chore: remove dev plans from published repo
Plans moved to central /home/rick/code/indiekit-dev/docs/plans/
This commit is contained in:
@@ -1,737 +0,0 @@
|
||||
# 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 }`.
|
||||
@@ -1,650 +0,0 @@
|
||||
# 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
|
||||
@@ -1,882 +0,0 @@
|
||||
# ActivityPub Reader Implementation Plan
|
||||
|
||||
Created: 2026-02-21
|
||||
Status: VERIFIED
|
||||
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
|
||||
|
||||
## Summary
|
||||
|
||||
**Goal:** Build a dedicated ActivityPub reader within the `@rmdes/indiekit-endpoint-activitypub` plugin, providing a timeline view of followed accounts' posts, a notifications stream, native AP interactions (like, boost, reply, follow/unfollow), and Micropub-based content creation — then remove the Microsub bridge dependency.
|
||||
|
||||
**Architecture:** The reader adds new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`) alongside new controllers, views, and a CSS stylesheet. Inbox listeners are refactored to store items natively instead of bridging to Microsub. Alpine.js provides client-side reactivity for interactions. Content creation uses two paths: direct Fedify `ctx.sendActivity()` for quick likes/boosts, and Micropub POST for replies that become blog posts (user chooses per-reply).
|
||||
|
||||
**Tech Stack:** Node.js/Express, MongoDB, Nunjucks templates, Alpine.js, Fedify SDK (`ctx.sendActivity()`, `ctx.lookupObject()`), Indiekit frontend components, CSS custom properties.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Timeline view showing posts from followed accounts with threading, content warnings, boosts, and rich media (images, video, audio, polls)
|
||||
- Tab-based filtering (All, Notes, Articles, Replies, Boosts, Media)
|
||||
- Notifications stream (likes, boosts, follows, mentions, replies received)
|
||||
- Native AP interactions: like, boost, reply (with choice of direct AP or Micropub), follow/unfollow
|
||||
- Mute/unmute (accounts and keywords), block/unblock
|
||||
- Profile view for remote actors (view posts, follow/unfollow, mute, block)
|
||||
- Compose form that submits via Micropub endpoint (for blog-worthy replies)
|
||||
- Custom CSS stylesheet with card-based layout inspired by Phanpy/Elk
|
||||
- Content warning spoiler toggle (Alpine.js)
|
||||
- Image gallery grid for multi-image posts
|
||||
- Video/audio embed rendering
|
||||
- Removal of Microsub bridge (`storeTimelineItem`, `getApChannelId`, lazy `microsub_items`/`microsub_channels` accessors)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Mastodon REST API compatibility (no mobile client support — would be a separate project)
|
||||
- Lists (organizing follows into named groups) — deferred to future plan
|
||||
- Local/Federated timeline distinction (single timeline of followed accounts only)
|
||||
- Full-text search within timeline items
|
||||
- Polls (rendering existing polls is in scope; creating polls is not)
|
||||
- Direct messages / conversations
|
||||
- Push notifications (browser notifications)
|
||||
- Infinite scroll (standard pagination is used)
|
||||
- Video/audio upload in compose form
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plugin is at v1.0.29+ with all federation hardening features complete
|
||||
- Fedify SDK available via `this._federation` on the plugin instance
|
||||
- MongoDB collections infrastructure in `index.js`
|
||||
- Indiekit frontend components available (`@indiekit/frontend`)
|
||||
- Alpine.js: **NOT loaded by Indiekit core**. The reader layout must explicitly load Alpine.js via a `<script>` CDN tag (e.g., `<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>`). The existing AP dashboard views use `x-data` directives — they work because the Cloudron deployment's CSP allows `cdn.jsdelivr.net` (see `nginx.conf`). The reader layout template must include Alpine.js in its `<head>` block.
|
||||
- `sanitize-html` package (add to `package.json` dependencies — used by Microsub plugin already, needed here for XSS prevention on remote content)
|
||||
|
||||
## 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:**
|
||||
- Route registration: See `index.js:143-169` — admin routes go in `get routes()` method, registered at `/admin/activitypub/*`
|
||||
- Controller pattern: Each controller exports async functions taking `(request, response)`. See `lib/controllers/dashboard.js` as example
|
||||
- View pattern: Views are `activitypub-*.njk` files in `views/`. They extend `document.njk` and use Indiekit frontend component macros (`card`, `button`, `badge`, `pagination`, etc.)
|
||||
- Collection registration: See `index.js:614-621` — register via `Indiekit.addCollection("name")` calls in `init()`, then store references via `this._collections.name = indiekitCollections.get("name")`
|
||||
- i18n: All user-visible strings go in `locales/en.json` under the `activitypub` namespace, referenced via `__("activitypub.reader.xxx")`
|
||||
- Asset serving: Place CSS/JS in `assets/` directory. Indiekit core serves at `/assets/@rmdes-indiekit-endpoint-activitypub/`. Reference from views with `<link>` tag.
|
||||
|
||||
- **Conventions:**
|
||||
- ESM modules throughout (`import`/`export`)
|
||||
- ISO 8601 strings for dates in MongoDB (except `published` in timeline items which uses `Date` for sorting queries)
|
||||
- Nunjucks templates use `{% from "xxx.njk" import component %}` for Indiekit frontend components
|
||||
- Alpine.js `x-data`, `x-show`, `x-on:click` for client-side interactivity (loaded explicitly in reader layout, NOT by Indiekit core)
|
||||
- CSRF protection: Indiekit core has no CSRF middleware. POST endpoints that trigger ActivityPub activities must validate a CSRF token. Use a simple pattern: generate a token per-session and embed as a hidden field in forms / include in `fetch()` headers. Validate on the server side before processing.
|
||||
|
||||
- **Key files:**
|
||||
- `index.js` — Plugin entry point, routes, collections, syndicator, follow/unfollow methods
|
||||
- `lib/inbox-listeners.js` — All inbox activity handlers (Follow, Like, Announce, Create, Delete, etc.)
|
||||
- `lib/federation-setup.js` — Fedify federation object configuration (dispatchers, queue, etc.)
|
||||
- `locales/en.json` — English translations
|
||||
- `views/activitypub-dashboard.njk` — Dashboard view (reference for card-grid patterns)
|
||||
- `views/activitypub-following.njk` — Following view (reference for list+pagination)
|
||||
|
||||
- **Gotchas:**
|
||||
- Fedify returns `Temporal.Instant` for dates, not JS `Date`. Convert with `new Date(Number(obj.published.epochMilliseconds))`
|
||||
- Fedify object properties are often async getters — `await actorObj.icon` not `actorObj.icon`
|
||||
- `ctx.sendActivity()` first argument is `{ identifier: handle }` where `handle` comes from plugin options
|
||||
- The plugin stores `this._federation` and creates context via `this._federation.createContext(new URL(this._publicationUrl), { handle, publicationUrl })`
|
||||
- Remote actor lookup uses `ctx.lookupObject("@handle@instance")` or `ctx.lookupObject("https://url")`
|
||||
- The AP plugin's asset directory is `assets/` at the package root, served at `/assets/@rmdes-indiekit-endpoint-activitypub/`
|
||||
|
||||
- **Domain context:**
|
||||
- ActivityPub activities: `Like` (favorite), `Announce` (boost/repost), `Create` (new post), `Follow`/`Undo(Follow)`, `Accept`, `Reject`, `Delete`, `Update`, `Block`, `Move`
|
||||
- Content warnings use the `summary` field on AP objects (Mastodon convention)
|
||||
- Boosts are `Announce` activities wrapping the original post — the reader must render the original post with boost attribution
|
||||
- Replies use `inReplyTo` linking to the parent post URL
|
||||
- Sensitive content uses the `sensitive` boolean on AP objects
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
- **Start command:** `cloudron exec --app rmendes.net` or locally `npm start` in the Cloudron container
|
||||
- **Port:** Indiekit on 8080 (behind nginx on 3000)
|
||||
- **Health check:** `curl https://rmendes.net/.well-known/webfinger?resource=acct:rick@rmendes.net`
|
||||
- **Deploy:** Build via `cloudron build --no-cache && cloudron update --app rmendes.net --no-backup`
|
||||
|
||||
## Feature Inventory — Microsub Bridge Being Replaced
|
||||
|
||||
### Files Being Modified (Bridge Removal)
|
||||
|
||||
| Old Code | Functions | Mapped to Task |
|
||||
|----------|-----------|----------------|
|
||||
| `lib/inbox-listeners.js` — function `storeTimelineItem()` (~line 468) | Timeline item storage from AP activities | Task 2 (store natively), Task 12 (remove bridge) |
|
||||
| `lib/inbox-listeners.js` — function `getApChannelId()` (~line 413) | Auto-creates Microsub "Fediverse" channel | Task 12 (remove) |
|
||||
| `index.js` — lazy accessors in `init()` (~line 638) | `microsub_items`, `microsub_channels` collection refs | Task 12 (remove) |
|
||||
| `lib/inbox-listeners.js` — Create handler (~line 262, calls `storeTimelineItem` at ~line 310) | Stores incoming posts via bridge | Task 2 (redirect to native storage) |
|
||||
|
||||
### Feature Mapping Verification
|
||||
|
||||
- [x] `storeTimelineItem()` → Task 2 (native `ap_timeline` storage)
|
||||
- [x] `getApChannelId()` → Task 12 (removed; no longer needed)
|
||||
- [x] Lazy Microsub collection accessors → Task 12 (removed)
|
||||
- [x] Inbox Create handler → Task 2 (rewired to native storage)
|
||||
- [x] Like/Announce inbox storage → Task 3 (notification storage)
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.**
|
||||
|
||||
- [x] Task 1: MongoDB collections and data models
|
||||
- [x] Task 2: Inbox listener refactor — native timeline storage (includes Delete/Update handling)
|
||||
- [x] Task 3: Inbox listener refactor — notification storage
|
||||
- [x] Task 4: Timeline controller and view
|
||||
- [x] Task 5: Reader CSS stylesheet
|
||||
- [x] Task 6: Notifications controller and view
|
||||
- [x] Task 7a: Interaction API — Like and Boost endpoints (with CSRF)
|
||||
- [x] Task 7b: Interaction UI — Like and Boost buttons (Alpine.js)
|
||||
- [x] Task 8: Compose form — Micropub reply path
|
||||
- [x] Task 9: Content warning toggles and rich media rendering
|
||||
- [x] Task 10: Mute, block, and tab filtering
|
||||
- [x] Task 11: Remote profile view
|
||||
- [x] Task 12: Remove Microsub bridge
|
||||
- [x] Task 13: Timeline retention cleanup
|
||||
|
||||
**Total Tasks:** 14 | **Completed:** 14 | **Remaining:** 0
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Task 1: MongoDB Collections and Data Models
|
||||
|
||||
**Objective:** Register new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`, `ap_interactions`) and create indexes for efficient querying.
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `index.js` — Register collections via `Indiekit.addCollection()` in `init()`, store references in `this._collections`, create indexes
|
||||
- Create: `lib/storage/timeline.js` — Timeline CRUD functions
|
||||
- Create: `lib/storage/notifications.js` — Notification CRUD functions
|
||||
- Create: `lib/storage/moderation.js` — Mute/block CRUD functions
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- `ap_timeline` schema:
|
||||
```js
|
||||
{
|
||||
uid: "https://remote.example/posts/123", // canonical AP object URL (dedup key)
|
||||
type: "note" | "article" | "boost", // boost = Announce wrapper
|
||||
url: "https://remote.example/posts/123",
|
||||
name: "Post Title" | null, // Articles only
|
||||
content: { text: "...", html: "..." },
|
||||
summary: "Content warning text" | null, // CW / spoiler
|
||||
sensitive: false, // Mastodon sensitive flag
|
||||
published: Date, // Date object for sort queries
|
||||
author: { name, url, photo, handle }, // handle = "@user@instance"
|
||||
category: ["tag1", "tag2"],
|
||||
photo: ["url1", "url2"],
|
||||
video: ["url1"],
|
||||
audio: ["url1"],
|
||||
inReplyTo: "https://parent-post-url" | null,
|
||||
boostedBy: { name, url, photo, handle } | null, // For Announce activities
|
||||
boostedAt: Date | null, // When the boost happened
|
||||
originalUrl: "https://original-post-url" | null, // For boosts: the wrapped object URL
|
||||
readBy: [],
|
||||
createdAt: "ISO string"
|
||||
}
|
||||
```
|
||||
- `ap_notifications` schema:
|
||||
```js
|
||||
{
|
||||
uid: "activity-id", // dedup key
|
||||
type: "like" | "boost" | "follow" | "mention" | "reply",
|
||||
actorUrl: "https://remote.example/@user",
|
||||
actorName: "Display Name",
|
||||
actorPhoto: "https://...",
|
||||
actorHandle: "@user@instance",
|
||||
targetUrl: "https://my-post-url" | null, // The post they liked/boosted/replied to
|
||||
targetName: "My Post Title" | null,
|
||||
content: { text: "...", html: "..." } | null, // For mentions/replies
|
||||
published: Date,
|
||||
read: false,
|
||||
createdAt: "ISO string"
|
||||
}
|
||||
```
|
||||
- `ap_muted`: `{ url: "actor-url", keyword: null, mutedAt: "ISO" }` — url OR keyword, not both
|
||||
- `ap_blocked`: `{ url: "actor-url", blockedAt: "ISO" }`
|
||||
- `ap_interactions`: `{ type: "like"|"boost", objectUrl: "https://...", activityId: "urn:uuid:...", createdAt: "ISO" }` — tracks outgoing interactions for undo support and UI state
|
||||
- Indexes:
|
||||
- `ap_timeline`: `{ uid: 1 }` unique, `{ published: -1 }` for timeline sort, `{ "author.url": 1 }` for profile view, `{ type: 1, published: -1 }` for tab filtering
|
||||
- `ap_notifications`: `{ uid: 1 }` unique, `{ published: -1 }` for sort, `{ read: 1 }` for unread count
|
||||
- `ap_muted`: `{ url: 1 }` unique (sparse), `{ keyword: 1 }` unique (sparse)
|
||||
- `ap_blocked`: `{ url: 1 }` unique
|
||||
- `ap_interactions`: `{ objectUrl: 1, type: 1 }` compound unique (one like/boost per object), `{ type: 1 }` for listing
|
||||
- Storage functions follow the pattern in Microsub's `lib/storage/items.js` — export pure functions that take `(collections, ...)` parameters
|
||||
- `addTimelineItem(collections, item)` uses atomic upsert: `updateOne({ uid }, { $setOnInsert: item }, { upsert: true })`
|
||||
- `getTimelineItems(collections, { before, after, limit, type, authorUrl })` returns cursor-paginated results
|
||||
- `addNotification(collections, notification)` uses atomic upsert
|
||||
- `getNotifications(collections, { before, limit })` returns paginated, newest-first
|
||||
- `getUnreadNotificationCount(collections)` returns count of `{ read: false }`
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] All five collections registered via `Indiekit.addCollection()` in `init()` (ap_timeline, ap_notifications, ap_muted, ap_blocked, ap_interactions)
|
||||
- [ ] Indexes created in `init()` method
|
||||
- [ ] `addTimelineItem` stores item and deduplicates by uid
|
||||
- [ ] `getTimelineItems` returns paginated results with before/after cursors
|
||||
- [ ] `addNotification` stores notification and deduplicates
|
||||
- [ ] `getNotifications` returns paginated newest-first
|
||||
- [ ] `getUnreadNotificationCount` returns correct count
|
||||
- [ ] Mute/block CRUD operations work (add, remove, list, check)
|
||||
- [ ] All storage functions have unit tests
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/timeline.js').then(m => console.log(Object.keys(m)))"` — exports exist
|
||||
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/notifications.js').then(m => console.log(Object.keys(m)))"` — exports exist
|
||||
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/moderation.js').then(m => console.log(Object.keys(m)))"` — exports exist
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Inbox Listener Refactor — Native Timeline Storage
|
||||
|
||||
**Objective:** Modify the inbox Create handler to store posts in `ap_timeline` instead of bridging to Microsub. Also handle Announce (boost) activities by storing the wrapped object with boost attribution.
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/inbox-listeners.js` — Refactor Create handler (~line 262) and Announce handler (~line 233) to store in `ap_timeline`, plus Delete/Update handlers for timeline cleanup
|
||||
- Modify: `package.json` — Add `sanitize-html` to dependencies
|
||||
- Create: `lib/timeline-store.js` — Helper that extracts data from Fedify objects and calls storage functions
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- The existing Create handler at `inbox-listeners.js` (function `registerInboxListeners`, Create section ~line 262) currently calls `storeTimelineItem()`. Replace that call with the new native storage
|
||||
- **CRITICAL — Announce handler bifurcation required:** The current Announce handler (line ~237) has an early return that ONLY processes boosts of our own content: `if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;`. This filter MUST be modified to create two code paths:
|
||||
1. **Boost of our content** (objectId starts with pubUrl) → store as notification (Task 3)
|
||||
2. **Boost from a followed account** (announcing actor is in our followers/following) → store in `ap_timeline` with `type: "boost"`
|
||||
3. **Both conditions true** (a followed account boosts our post) → store BOTH notification AND timeline item
|
||||
- For timeline boosts: fetch the wrapped object via `await announce.getObject()` (the current handler only reads `announce.objectId` URL, NOT the full object), extract its data, then store with `type: "boost"` and `boostedBy` populated from the announcing actor
|
||||
- To check if the announcing actor is followed: query `ap_followers` or `ap_following` collection for the actor URL
|
||||
- Keep the same Fedify object→data extraction logic from `storeTimelineItem` (content, photos, videos, tags, etc.) but move it to a reusable `extractObjectData(object, actorObj)` function in `lib/timeline-store.js`
|
||||
- **CRITICAL: HTML sanitization** — Remote content HTML MUST be sanitized before storage using `sanitize-html` (same library used in Microsub's `lib/webmention/verifier.js`). Allow safe tags: `a`, `p`, `br`, `em`, `strong`, `blockquote`, `ul`, `ol`, `li`, `code`, `pre`, `span`, `h1`-`h6`, `img`. Allow `href` on `a`, `src`/`alt` on `img`, `class` on `span` (for Mastodon custom emoji). Strip all other HTML including `<script>`, `<style>`, event handlers. This prevents XSS when rendering content with Nunjucks `| safe` filter
|
||||
- Check muted/blocked before storing — skip items from muted URLs or containing muted keywords
|
||||
- The existing `storeTimelineItem()` and `getApChannelId()` functions remain for now (cleaned up in Task 12)
|
||||
- For replies (`inReplyTo`), store the parent URL so the frontend can render threading context
|
||||
- **Delete activity handling:** Modify the existing Delete handler (`inbox-listeners.js` ~line 318) to also remove items from `ap_timeline` (currently only deletes from `ap_activities`). When a remote user deletes a post, remove the corresponding `ap_timeline` entry by uid.
|
||||
- **Update activity handling:** Modify the existing Update handler (`inbox-listeners.js` ~line 345) to also update `ap_timeline` items. Currently it only refreshes follower/actor profile data. When a remote user edits a post (Update activity), re-extract the content and update the timeline item. This prevents showing stale content for edited posts.
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Create activities from followed accounts stored in `ap_timeline` with all fields populated
|
||||
- [ ] Announce (boost) activities stored with `type: "boost"`, `boostedBy`, and the original post content
|
||||
- [ ] Muted actors' posts are skipped during storage
|
||||
- [ ] Blocked actors' posts are skipped during storage
|
||||
- [ ] Posts containing muted keywords are skipped
|
||||
- [ ] Duplicate posts (same uid) are not created
|
||||
- [ ] Remote HTML content sanitized before storage (no `<script>`, `<style>`, event handlers)
|
||||
- [ ] Delete activities remove corresponding items from `ap_timeline`
|
||||
- [ ] Update activities refresh content of existing `ap_timeline` items
|
||||
- [ ] Tests verify Create → timeline storage flow
|
||||
- [ ] Tests verify Announce → timeline storage with boost attribution
|
||||
- [ ] Tests verify Delete → timeline item removal
|
||||
- [ ] Tests verify Update → timeline item content refresh
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Integration test: Send a mock Create activity, verify it appears in `ap_timeline` collection
|
||||
- Integration test: Send a mock Announce activity, verify boost attribution stored correctly
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Inbox Listener Refactor — Notification Storage
|
||||
|
||||
**Objective:** Store incoming Like, Announce (of our posts), Follow, and mention/reply activities as notifications in `ap_notifications`.
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/inbox-listeners.js` — Add notification storage calls in Like handler (`activity instanceof Like`), Announce handler (`activity instanceof Announce`), Follow handler (`activity instanceof Follow`), Create handler for mentions/replies
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- **Like handler** (in `registerInboxListeners`, search for `activity instanceof Like`): already logs to `ap_activities` and filters to only likes of our own posts. This filter is correct for notifications. Add a call to `addNotification()` with `type: "like"`, including the actor info and the liked post URL
|
||||
- **Announce handler** (search for `activity instanceof Announce`): the dual-path logic from Task 2 handles timeline storage. For notifications, when someone boosts OUR post (objectId starts with pubUrl), store as notification `type: "boost"`
|
||||
- Follow handler: store as notification `type: "follow"` when someone new follows us
|
||||
- Create handler: if the post is a reply TO one of our posts (check `inReplyTo` against our publication URL), store as `type: "reply"`; if it mentions us (check tags for Mention with our actor URL), store as `type: "mention"`
|
||||
- Notification dedup by activity ID or constructed uid (e.g., `like:${actorUrl}:${objectUrl}`)
|
||||
- Extract actor info (name, photo, handle) from Fedify actor object — use same `extractActorInfo()` helper
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Likes of our posts create notification with type "like"
|
||||
- [ ] Boosts of our posts create notification with type "boost"
|
||||
- [ ] New follows create notification with type "follow"
|
||||
- [ ] Replies to our posts create notification with type "reply"
|
||||
- [ ] Mentions of our actor create notification with type "mention"
|
||||
- [ ] Notifications are deduplicated by uid
|
||||
- [ ] All notification types include correct actor info and target post info
|
||||
- [ ] Tests verify each notification type is stored correctly
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Unit tests for notification storage from each activity type
|
||||
- Verify on live site: receive a like → check `ap_notifications` collection via MongoDB query
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Timeline Controller and View
|
||||
|
||||
**Objective:** Create the reader timeline page at `/admin/activitypub/reader` showing posts from followed accounts with pagination, and a reader navigation sidebar.
|
||||
|
||||
**Dependencies:** Task 1, Task 2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `lib/controllers/reader.js` — Timeline controller
|
||||
- Create: `views/layouts/reader.njk` — Reader layout (extends `document.njk`, adds Alpine.js CDN `<script>` tag and reader stylesheet `<link>`)
|
||||
- Create: `views/activitypub-reader.njk` — Timeline view (extends `layouts/reader.njk`)
|
||||
- Create: `views/partials/ap-item-card.njk` — Timeline item card partial
|
||||
- Modify: `index.js` — Add reader routes and navigation item
|
||||
- Modify: `locales/en.json` — Add reader i18n strings
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Route: `GET /admin/activitypub/reader` → timeline (default tab: "All")
|
||||
- Route: `GET /admin/activitypub/reader?tab=notes|articles|replies|boosts|media` → filtered tab
|
||||
- Route: `GET /admin/activitypub/reader?before=cursor` → pagination
|
||||
- Navigation: Add "Reader" as first navigation item (before Dashboard) with an unread notification count badge
|
||||
- Timeline controller calls `getTimelineItems()` with optional `type` filter based on tab
|
||||
- Item card renders: author (avatar + name + handle), content (HTML), photos (grid), video (embed), audio (player), categories/tags, published date, interaction buttons (like, boost, reply, profile link)
|
||||
- Card layout inspired by Phanpy/Elk: clean white cards with subtle shadows, rounded corners, generous spacing
|
||||
- Use cursor-based pagination (same pattern as Microsub: `before`/`after` query params)
|
||||
- Mark items as read when the timeline page loads (or use a "mark all read" button)
|
||||
- The partial `ap-item-card.njk` renders a single timeline item — reused in both timeline and profile views
|
||||
- For boosts: show "🔁 {booster} boosted" header above the original post card
|
||||
- For replies: show "↩ Replying to {parentAuthorUrl}" link above content
|
||||
- **HTML rendering:** Use `{{ item.content.html | safe }}` in templates — this is safe because content was sanitized at storage time (Task 2). Do NOT use `| safe` on any unsanitized user input
|
||||
- **Navigation architecture:** Indiekit's `get navigationItems()` returns flat top-level items in the sidebar. The AP plugin currently returns one item ("ActivityPub" → `/activitypub`). Change this to return "Reader" as the primary navigation item (→ `/activitypub/reader`), and add sub-navigation within the reader views (Dashboard, Reader, Notifications, Following, Settings/Moderation) using a local `<nav>` in the view template — NOT via `get navigationItems()` (which only handles top-level sidebar items)
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] `/admin/activitypub/reader` renders timeline with posts from followed accounts
|
||||
- [ ] Item cards show author info, content, media, tags, date, and interaction buttons
|
||||
- [ ] Tab filtering works for notes, articles, replies, boosts, media
|
||||
- [ ] Pagination works with cursor-based before/after
|
||||
- [ ] Boost attribution renders correctly (boosted by header)
|
||||
- [ ] Reply context renders (replying to link)
|
||||
- [ ] Navigation item appears in sidebar with Reader label
|
||||
- [ ] Empty state shown when timeline is empty
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `curl -s https://rmendes.net/admin/activitypub/reader -H "Cookie: ..." | grep -c "ap-item-card"` — returns item count
|
||||
- Visual check via `playwright-cli open https://rmendes.net/admin/activitypub/reader`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Reader CSS Stylesheet
|
||||
|
||||
**Objective:** Create a custom CSS stylesheet for the AP reader with card-based layout, image grids, and responsive design.
|
||||
|
||||
**Dependencies:** Task 4
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `assets/reader.css` — Reader stylesheet
|
||||
- Modify: `views/activitypub-reader.njk` — Link stylesheet
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Follow the pattern from Microsub: `<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">`
|
||||
- Use Indiekit CSS custom properties: `--space-s`, `--space-m`, `--space-l`, `--color-offset`, `--border-radius`, `--color-text`, `--color-background`, etc.
|
||||
- Card styles: `.ap-card` — white background, border, rounded corners, padding, margin-bottom
|
||||
- Author header: `.ap-card__author` — flexbox row with avatar (40px circle), name (bold), handle (@user@instance, muted), timestamp (right-aligned, relative)
|
||||
- Content: `.ap-card__content` — prose-like styling, max-width for readability
|
||||
- Image grid: `.ap-card__gallery` — CSS Grid, 2-column for 2 images, 2x2 for 3-4 images, rounded corners, gap
|
||||
- Video embed: `.ap-card__video` — responsive 16:9 container
|
||||
- Audio player: `.ap-card__audio` — full-width native audio element
|
||||
- Content warning: `.ap-card__cw` — blurred/collapsed content behind a "Show more" button
|
||||
- Boost header: `.ap-card__boost` — small text with repost icon, muted color
|
||||
- Reply context: `.ap-card__reply-to` — small text with reply icon, linked to parent
|
||||
- Interaction buttons: `.ap-card__actions` — flexbox row, icon buttons with count labels
|
||||
- Tab bar: `.ap-tabs` — horizontal tabs, active tab highlighted
|
||||
- Notifications: `.ap-notification` — compact card with icon, actor, action description, post excerpt
|
||||
- Responsive: Stack to single column on mobile, full-width cards
|
||||
- Dark mode: Use Indiekit's `prefers-color-scheme` media query with its CSS custom properties
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Cards render with clean, readable layout
|
||||
- [ ] Image gallery works for 1-4 images with proper grid
|
||||
- [ ] Content warnings show blurred/collapsed state
|
||||
- [ ] Interaction buttons aligned horizontally below content
|
||||
- [ ] Tab bar renders with active state
|
||||
- [ ] Responsive on mobile viewport
|
||||
- [ ] Uses Indiekit CSS custom properties (not hardcoded colors)
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `playwright-cli open https://rmendes.net/admin/activitypub/reader` → screenshot → visual check
|
||||
- `playwright-cli resize 375 812` → mobile check
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Notifications Controller and View
|
||||
|
||||
**Objective:** Create the notifications page at `/admin/activitypub/reader/notifications` showing likes, boosts, follows, mentions, and replies received.
|
||||
|
||||
**Dependencies:** Task 3, Task 5
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/controllers/reader.js` — Add notifications controller function
|
||||
- Create: `views/activitypub-notifications.njk` — Notifications view (extends `layouts/reader.njk`)
|
||||
- Create: `views/partials/ap-notification-card.njk` — Notification card partial
|
||||
- Modify: `index.js` — Add notification route
|
||||
- Modify: `locales/en.json` — Add notification i18n strings
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Route: `GET /admin/activitypub/reader/notifications`
|
||||
- Notification card is more compact than timeline card: icon + actor name + action text + post excerpt + timestamp
|
||||
- Group similar notifications? No — keep it chronological for simplicity
|
||||
- Mark notifications as read when the page loads (set `read: true` on all displayed)
|
||||
- Unread count shown as badge on "Reader" navigation item (combine timeline and notification counts)
|
||||
- Notification type → display:
|
||||
- `like`: "❤ {actor} liked your post {title}" with link to the post
|
||||
- `boost`: "🔁 {actor} boosted your post {title}"
|
||||
- `follow`: "👤 {actor} followed you" with link to their profile
|
||||
- `reply`: "💬 {actor} replied to your post {title}" with content preview
|
||||
- `mention`: "@ {actor} mentioned you" with content preview
|
||||
- Pagination: same cursor-based pattern as timeline
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] `/admin/activitypub/reader/notifications` renders notification stream
|
||||
- [ ] Each notification type displays correctly with icon, actor, action, and target
|
||||
- [ ] Notifications marked as read when page loads
|
||||
- [ ] Unread count appears on Reader navigation badge
|
||||
- [ ] Pagination works for notifications
|
||||
- [ ] Empty state shown when no notifications
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `curl -s https://rmendes.net/admin/activitypub/reader/notifications -H "Cookie: ..."` — renders HTML
|
||||
- Check unread badge updates after viewing notifications
|
||||
|
||||
---
|
||||
|
||||
### Task 7a: Interaction API — Like and Boost Endpoints
|
||||
|
||||
**Objective:** Create the server-side API endpoints for Like, Unlike, Boost, and Unboost that send ActivityPub activities via Fedify.
|
||||
|
||||
**Dependencies:** Task 1, Task 4
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `lib/controllers/interactions.js` — Handle like/boost/unlike/unboost POST requests (receives plugin instance via injection)
|
||||
- Create: `lib/csrf.js` — Simple CSRF token generation and validation middleware
|
||||
- Modify: `index.js` — Add interaction routes, inject plugin instance into controller (same pattern as `refollowPauseController(mp, this)` at `index.js:165-166`)
|
||||
- Modify: `locales/en.json` — Add interaction i18n strings
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- **CRITICAL — Federation context injection:** Regular controllers only have access to `request.app.locals.application` — they do NOT have `this._federation` or `this._collections`. The interaction controller needs federation context to call `ctx.sendActivity()`. Follow the refollow controller pattern: in `index.js`, pass the plugin instance when registering routes: `interactionController(mp, this)`. The controller factory returns route handlers with access to `pluginInstance._federation` and `pluginInstance._collections`. This same pattern is needed for ALL controllers that send ActivityPub activities (interactions, compose, moderation/block).
|
||||
- **CSRF protection:** Generate a per-session CSRF token (store in `request.session.csrfToken`). Embed as hidden field in forms and as `X-CSRF-Token` header in `fetch()` requests. Validate on all POST endpoints before processing. Create `lib/csrf.js` with `generateToken(session)` and `validateToken(request)` functions.
|
||||
- Routes:
|
||||
- `POST /admin/activitypub/reader/like` — body: `{ url: "post-url", _csrf: "token" }` → sends Like activity
|
||||
- `POST /admin/activitypub/reader/unlike` — body: `{ url: "post-url", _csrf: "token" }` → sends Undo(Like)
|
||||
- `POST /admin/activitypub/reader/boost` — body: `{ url: "post-url", _csrf: "token" }` → sends Announce activity
|
||||
- `POST /admin/activitypub/reader/unboost` — body: `{ url: "post-url", _csrf: "token" }` → sends Undo(Announce)
|
||||
- Implementation pattern (like):
|
||||
1. Validate CSRF token
|
||||
2. Look up the post author via the post URL using `ctx.lookupObject(url)`
|
||||
3. Construct a `Like` activity with the post as object
|
||||
4. Send via `ctx.sendActivity({ identifier: handle }, recipient, likeActivity)`
|
||||
5. Store the interaction in `ap_interactions` collection
|
||||
6. Return JSON response `{ success: true, type: "like", objectUrl: "..." }`
|
||||
- For Announce (boost): construct `Announce` activity wrapping the original post, send to followers via shared inbox
|
||||
- Track interactions in `ap_interactions` collection `{ type: "like"|"boost", objectUrl: "...", activityId: "urn:uuid:...", createdAt: "ISO" }` — allows undo by looking up the activity ID
|
||||
- Error handling: return JSON `{ success: false, error: "message" }` with appropriate HTTP status
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Like endpoint sends Like activity to remote actor's inbox
|
||||
- [ ] Unlike endpoint sends Undo(Like) activity
|
||||
- [ ] Boost endpoint sends Announce activity to followers
|
||||
- [ ] Unboost endpoint sends Undo(Announce) activity
|
||||
- [ ] CSRF token validated on all POST endpoints
|
||||
- [ ] Interaction tracking persisted in `ap_interactions`
|
||||
- [ ] JSON response returned for all endpoints
|
||||
- [ ] Tests verify activity construction and sending
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Like a post via `curl -X POST .../reader/like -d '{"url":"...","_csrf":"..."}'` → check JSON response
|
||||
- Verify `ap_interactions` collection has the record
|
||||
- Check remote instance shows the like (manual)
|
||||
|
||||
---
|
||||
|
||||
### Task 7b: Interaction UI — Like and Boost Buttons
|
||||
|
||||
**Objective:** Add Alpine.js-powered like/boost buttons to timeline cards with optimistic updates and error handling.
|
||||
|
||||
**Dependencies:** Task 7a
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `views/partials/ap-item-card.njk` — Add like/boost buttons with Alpine.js reactivity
|
||||
- Modify: `lib/controllers/reader.js` — Query `ap_interactions` on timeline load to populate liked/boosted state, pass CSRF token to template
|
||||
- Modify: `assets/reader.css` — Add interaction button styles (if not already in Task 5)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Use Alpine.js `x-data` on each card to track `liked` and `boosted` state — initialized from server data
|
||||
- Timeline controller queries `ap_interactions` for all displayed item URLs, builds a Set of liked/boosted URLs, passes to template
|
||||
- Button click makes `fetch()` POST with CSRF token in `X-CSRF-Token` header, toggles visual state immediately (optimistic update)
|
||||
- Error handling: if the API returns `{ success: false }`, revert the visual state and show a brief error message
|
||||
- Button styling: heart icon for like (filled when liked), repost icon for boost (highlighted when boosted)
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Like/boost buttons appear on every timeline card
|
||||
- [ ] Button state reflects server state on page load (already-liked/boosted show active)
|
||||
- [ ] Clicking like sends POST and toggles button visually
|
||||
- [ ] Clicking boost sends POST and toggles button visually
|
||||
- [ ] Failed interactions revert button state and show error
|
||||
- [ ] CSRF token included in all fetch() requests
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `playwright-cli open .../reader` → find a post → click like → verify button state changes
|
||||
- Reload page → verify liked state persists
|
||||
- Unlike → verify button reverts
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Compose Form — Micropub Reply Path
|
||||
|
||||
**Objective:** Add a compose form for replying to posts, with the option to submit via Micropub (creating a blog post) or via direct AP reply.
|
||||
|
||||
**Dependencies:** Task 4, Task 7a
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/controllers/reader.js` — Add compose and submitCompose functions
|
||||
- Create: `views/activitypub-compose.njk` — Compose form view
|
||||
- Modify: `views/partials/ap-item-card.njk` — Add reply button linking to compose
|
||||
- Modify: `index.js` — Add compose routes
|
||||
- Modify: `locales/en.json` — Add compose i18n strings
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Routes:
|
||||
- `GET /admin/activitypub/reader/compose?replyTo=url` — Show compose form
|
||||
- `POST /admin/activitypub/reader/compose` — Submit reply
|
||||
- Compose form has two submit paths (radio toggle):
|
||||
1. **"Post as blog reply" (Micropub)** — Submits to Micropub endpoint as `in-reply-to` + `content`, creating a permanent blog post that gets syndicated to AP via the existing syndicator pipeline
|
||||
2. **"Quick reply" (Direct AP)** — Constructs a Create(Note) activity with `inReplyTo` and sends directly via `ctx.sendActivity()` to the author's inbox + followers. No blog post created.
|
||||
- The form pattern borrows from Microsub compose (`views/compose.njk`): textarea, hidden in-reply-to field, syndication target checkboxes (for Micropub path)
|
||||
- For the quick reply path: the Note is ephemeral (not stored as a blog post) but IS stored in the timeline as the user's own post
|
||||
- Fetch syndication targets from Micropub config endpoint (same pattern as Microsub compose at `reader.js:403-407`)
|
||||
- **Micropub endpoint discovery:** Access via `request.app.locals.application.micropubEndpoint` (same as Microsub). Auth token from `request.session.access_token`. Build absolute URL from relative endpoint path using `application.url` as base.
|
||||
- Character counter for quick reply mode (AP convention: 500 chars)
|
||||
- Reply context: show the parent post above the compose form (fetch via stored timeline item or `ctx.lookupObject()`)
|
||||
- **Federation context injection:** The compose controller needs plugin instance for the direct AP reply path (same `ctx.sendActivity()` pattern as Task 7a). Register via same injection pattern.
|
||||
- **CSRF protection:** Both form submit paths must validate CSRF token (reuse `lib/csrf.js` from Task 7a)
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Compose form renders with reply context (parent post preview)
|
||||
- [ ] "Post as blog reply" submits via Micropub and redirects back to reader
|
||||
- [ ] "Quick reply" sends Create(Note) directly via Fedify
|
||||
- [ ] Quick reply includes proper `inReplyTo` reference
|
||||
- [ ] Quick reply is delivered to the original author's inbox
|
||||
- [ ] Syndication targets appear for Micropub path
|
||||
- [ ] Character counter works in quick reply mode
|
||||
- [ ] Error handling for both paths
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Post a Micropub reply → verify blog post created and syndicated
|
||||
- Post a quick reply → verify it appears on the remote instance as a reply
|
||||
- Check `in-reply-to` is correctly set in both cases
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Content Warning Toggles and Rich Media Rendering
|
||||
|
||||
**Objective:** Implement content warning spoiler toggle (click to reveal), image gallery grid, and video/audio embeds in timeline cards.
|
||||
|
||||
**Dependencies:** Task 4, Task 5
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `views/partials/ap-item-card.njk` — Add CW toggle, gallery grid, video/audio
|
||||
- Modify: `assets/reader.css` — Add styles for CW, gallery, video, audio
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- **Content warnings:** Posts with `summary` field (Mastodon CW) render as:
|
||||
- Visible: CW text (the summary)
|
||||
- Hidden (behind button): The actual content + media
|
||||
- Alpine.js `x-data="{ revealed: false }"` + `x-show="revealed"` + `@click="revealed = !revealed"`
|
||||
- Button text toggles: "Show more" / "Show less"
|
||||
- `sensitive: true` without summary: "Sensitive content" as default CW text
|
||||
- **Image gallery:**
|
||||
- 1 image: Full width, max-height with object-fit: cover
|
||||
- 2 images: Side-by-side (50/50 grid)
|
||||
- 3 images: First image full width, second and third side-by-side below
|
||||
- 4+ images: 2x2 grid, "+N more" overlay on 4th image if >4
|
||||
- All images rounded corners, gap between
|
||||
- Click to expand? Lightbox is out of scope — just link to full image
|
||||
- **Video:** `<video>` tag with controls, poster if available, responsive wrapper
|
||||
- **Audio:** `<audio>` tag with controls, full width
|
||||
- **Polls:** Render poll options as a list with vote counts if available (read-only display)
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Content warnings display summary text with "Show more" button
|
||||
- [ ] Clicking "Show more" reveals hidden content and media
|
||||
- [ ] Clicking "Show less" re-hides content
|
||||
- [ ] Image gallery renders correctly for 1, 2, 3, and 4+ images
|
||||
- [ ] Videos render with native player controls
|
||||
- [ ] Audio renders with native player controls
|
||||
- [ ] Sensitive posts without summary show "Sensitive content" label
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `playwright-cli open https://rmendes.net/admin/activitypub/reader`
|
||||
- Find a post with CW → click "Show more" → content reveals
|
||||
- Find a post with multiple images → verify grid layout
|
||||
- `playwright-cli snapshot` → verify structure
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Mute, Block, and Tab Filtering
|
||||
|
||||
**Objective:** Add mute/block functionality for actors and keywords, and implement tab-based timeline filtering.
|
||||
|
||||
**Dependencies:** Task 1, Task 4
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `lib/controllers/moderation.js` — Mute/block controller
|
||||
- Modify: `lib/controllers/reader.js` — Add tab filtering logic, mute/block from profile
|
||||
- Create: `views/activitypub-moderation.njk` — Moderation settings page (list muted/blocked)
|
||||
- Modify: `views/partials/ap-item-card.njk` — Add mute/block in item card dropdown menu
|
||||
- Modify: `index.js` — Add moderation routes
|
||||
- Modify: `locales/en.json` — Add moderation i18n strings
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Routes:
|
||||
- `POST /admin/activitypub/reader/mute` — body: `{ url: "actor-url" }` or `{ keyword: "text" }`
|
||||
- `POST /admin/activitypub/reader/unmute` — body: `{ url: "actor-url" }` or `{ keyword: "text" }`
|
||||
- `POST /admin/activitypub/reader/block` — body: `{ url: "actor-url" }` → also sends Block activity
|
||||
- `POST /admin/activitypub/reader/unblock` — body: `{ url: "actor-url" }` → sends Undo(Block)
|
||||
- `GET /admin/activitypub/reader/moderation` — View muted/blocked lists
|
||||
- Mute: hide from timeline but don't notify the remote actor. Filter at query time: exclude items where `author.url` is in muted list or content matches muted keyword
|
||||
- Block: send `Block` activity to remote actor via `ctx.sendActivity()` AND hide from timeline. On block: also remove existing timeline items from that actor. **Federation context injection** needed for Block/Undo(Block) — same plugin instance pattern as Task 7a.
|
||||
- **CSRF protection:** All POST endpoints (mute/unmute/block/unblock) must validate CSRF token (reuse `lib/csrf.js` from Task 7a)
|
||||
- Tab filtering implementation: `getTimelineItems()` accepts a `type` parameter. Map tabs:
|
||||
- All → no filter
|
||||
- Notes → `type: "note"`
|
||||
- Articles → `type: "article"`
|
||||
- Replies → items where `inReplyTo` is not null
|
||||
- Boosts → `type: "boost"`
|
||||
- Media → items where `photo`, `video`, or `audio` arrays are non-empty
|
||||
- Each tab shows a count badge? No — too expensive on every page load. Just tab labels.
|
||||
- Card dropdown (three dots menu): "Mute @user", "Block @user", "Mute keyword..."
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Muting an actor hides their posts from timeline
|
||||
- [ ] Muting a keyword hides matching posts from timeline
|
||||
- [ ] Blocking an actor sends Block activity and removes their posts
|
||||
- [ ] Unblocking sends Undo(Block)
|
||||
- [ ] Moderation settings page lists all muted actors, keywords, and blocked actors
|
||||
- [ ] Can unmute/unblock from the settings page
|
||||
- [ ] Tab filtering returns correct subset of timeline items
|
||||
- [ ] Card dropdown has mute/block actions
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Mute an actor → verify their posts disappear from timeline
|
||||
- Block an actor → verify Block activity sent + posts removed
|
||||
- Switch between tabs → verify correct filtering
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Remote Profile View
|
||||
|
||||
**Objective:** Create a profile page for viewing remote actors, showing their info and recent posts, with follow/unfollow, mute, and block buttons.
|
||||
|
||||
**Dependencies:** Task 4, Task 7b, Task 10
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/controllers/reader.js` — Add profile controller function
|
||||
- Create: `views/activitypub-remote-profile.njk` — Remote actor profile view (**NOT** `activitypub-profile.njk` — that file already exists for the user's own profile editor)
|
||||
- Modify: `assets/reader.css` — Add profile view styles
|
||||
- Modify: `index.js` — Add profile route
|
||||
- Modify: `locales/en.json` — Add profile i18n strings
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Route: `GET /admin/activitypub/reader/profile?url=actor-url` or `GET /admin/activitypub/reader/profile?handle=@user@instance`
|
||||
- Fetch actor info via `ctx.lookupObject(url)` — returns Fedify Actor with name, summary, icon, image, followerCount, followingCount, etc.
|
||||
- Show: avatar, header image, display name, handle, bio, follower/following counts, profile links
|
||||
- Show recent posts from that actor in the timeline (filter `ap_timeline` by `author.url`)
|
||||
- If the actor is not followed, posts won't be in the local timeline — show a message "Follow to see their posts" or attempt to fetch their outbox via `ctx.traverseCollection(outbox)` (limited, slow)
|
||||
- Decision: For now, only show locally-stored posts (from following). If not following, show profile info only with a "Follow to see their posts in your timeline" CTA
|
||||
- Action buttons: Follow/Unfollow (reuse existing `followActor`/`unfollowActor` methods from `index.js`), Mute, Block
|
||||
- Link to external profile: "View on {instance}" link to the actor's URL
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Profile page renders remote actor info (avatar, name, handle, bio)
|
||||
- [ ] Profile shows header image if available
|
||||
- [ ] Profile shows follower/following counts
|
||||
- [ ] Posts from that actor shown below profile (if following)
|
||||
- [ ] Follow/unfollow button works
|
||||
- [ ] Mute/block buttons work from profile
|
||||
- [ ] "View on {instance}" external link present
|
||||
- [ ] Graceful handling when actor lookup fails
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Navigate to a followed actor's profile → verify info and posts display
|
||||
- Follow/unfollow from profile → verify state changes
|
||||
- Navigate to an unknown handle → verify graceful error
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Remove Microsub Bridge
|
||||
|
||||
**Objective:** Remove all Microsub bridge code from the AP plugin — `storeTimelineItem()`, `getApChannelId()`, and the lazy `microsub_items`/`microsub_channels` collection accessors.
|
||||
|
||||
**Dependencies:** Task 2, Task 3, Task 4, Task 6 (all reader functionality must be working first)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/inbox-listeners.js` — Remove `storeTimelineItem()` function (lines 455-576), remove `getApChannelId()` function (lines 400-453), remove any remaining calls to these functions
|
||||
- Modify: `index.js` — Remove lazy `microsub_items` and `microsub_channels` getter/accessors (lines 638-643), remove any `microsub` references from collection handling
|
||||
- Modify: `lib/inbox-listeners.js` — Remove the `storeTimelineItem()` call in the Create handler (should already be replaced in Task 2, but verify)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- This is a cleanup task — all replacement functionality should already be working via Tasks 2-6
|
||||
- The Microsub plugin itself remains fully functional — it still manages its own RSS/Atom feeds, channels, and items. We're only removing the AP plugin's code that bridges INTO Microsub collections
|
||||
- After removal, the `microsub_items` collection may still contain old AP items (with `source.type: "activitypub"`) — these can be left in place or cleaned up manually by the user
|
||||
- Verify that the Microsub plugin's "Fediverse" channel still works for non-AP content (it's created by `getApChannelId` which we're removing). If no non-AP content uses it, the channel becomes orphaned — that's fine.
|
||||
- Test that the AP plugin starts cleanly without any Microsub collections referenced
|
||||
- Bump version in `package.json` for this change since it removes a dependency
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] `storeTimelineItem()` function removed from `inbox-listeners.js`
|
||||
- [ ] `getApChannelId()` function removed from `inbox-listeners.js`
|
||||
- [ ] No references to `microsub_items` or `microsub_channels` in any AP plugin file
|
||||
- [ ] No `import` or `require` of Microsub-related modules
|
||||
- [ ] Plugin starts without errors when Microsub plugin is not loaded
|
||||
- [ ] Plugin starts without errors when Microsub plugin IS loaded (no conflict)
|
||||
- [ ] Existing AP timeline/notification functionality unaffected
|
||||
- [ ] Version bumped in `package.json`
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `grep -r "microsub" /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub/lib/ /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub/index.js` — returns zero matches
|
||||
- `node -e "import('./index.js')"` — plugin loads without errors
|
||||
- Deploy to Cloudron → verify reader works, verify Microsub reader still works independently
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Timeline Retention Cleanup
|
||||
|
||||
**Objective:** Implement automatic cleanup of old timeline items to prevent unbounded collection growth.
|
||||
|
||||
**Dependencies:** Task 1, Task 2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `lib/timeline-cleanup.js` — Retention cleanup function
|
||||
- Modify: `index.js` — Schedule periodic cleanup (e.g., on server startup and via a setInterval)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Keep the last 1000 timeline items (configurable via plugin options: `timelineRetention: 1000`)
|
||||
- Cleanup runs on plugin `init()` and then every 24 hours via `setInterval`
|
||||
- Implementation: `ap_timeline.deleteMany({ published: { $lt: oldestKeepDate } })` — find the published date of the 1000th newest item, delete everything older
|
||||
- Alternative: count-based: `ap_timeline.find().sort({ published: -1 }).skip(1000).forEach(doc => delete)`
|
||||
- Decision: Use count-based approach — simpler, handles edge cases where many items share the same date
|
||||
- Also clean up corresponding `ap_interactions` entries for deleted timeline items (remove stale like/boost tracking)
|
||||
- Log cleanup results: "Timeline cleanup: removed N items older than {date}"
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Cleanup function removes items beyond retention limit
|
||||
- [ ] Cleanup runs on startup and periodically
|
||||
- [ ] Retention limit is configurable via plugin options
|
||||
- [ ] Stale `ap_interactions` entries cleaned up alongside timeline items
|
||||
- [ ] Cleanup logged for diagnostics
|
||||
- [ ] Tests verify retention limit is enforced
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Insert 1050 test items → run cleanup → verify only 1000 remain
|
||||
- Verify `ap_interactions` for removed items are also deleted
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests:** Storage functions (timeline CRUD, notification CRUD, moderation CRUD), data extraction helpers (`extractObjectData`, `extractActorInfo`), tab filtering logic
|
||||
- **Integration tests:** Bash-based tests in `/home/rick/code/indiekit-dev/activitypub-tests/` — add new tests for reader endpoints (authenticated GET requests), interaction endpoints (POST like/boost), notification counts
|
||||
- **Manual verification:**
|
||||
- Use `playwright-cli` to verify reader UI renders correctly
|
||||
- Send real AP interactions from a test Mastodon account to verify inbox→timeline→notification flow
|
||||
- Compose replies via both paths (Micropub and direct AP) and verify they appear on remote instances
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| `ctx.lookupObject()` slow for remote actors (profile view) | High | Medium | Cache actor info in `ap_timeline` author fields; only call lookupObject once per profile visit, not per card |
|
||||
| `ctx.sendActivity()` for likes/boosts may fail silently | Medium | Medium | Store interaction attempt in `ap_interactions` with status field; show error state in UI if delivery fails |
|
||||
| Content warnings/sensitive flag not consistently set by remote servers | Medium | Low | Treat `summary` presence as CW signal (Mastodon convention); fall back to "Sensitive content" for `sensitive: true` without summary |
|
||||
| Image gallery CSS breaks with very large images | Low | Low | Use `object-fit: cover` with max-height constraints; all images in grid cells |
|
||||
| Removing Microsub bridge while user still has AP items in Microsub channel | Medium | Low | Leave existing items in `microsub_items` untouched; they'll still be readable through the Microsub reader. Only new AP items go to `ap_timeline` |
|
||||
| Alpine.js optimistic updates for like/boost may desync with server state | Medium | Low | On page reload, always read server state from timeline items; track interactions in `ap_interactions` collection |
|
||||
| CSRF attacks on POST endpoints could trigger unwanted AP activities | Medium | High | All POST endpoints validate per-session CSRF token via `lib/csrf.js`; token embedded in forms and `fetch()` headers |
|
||||
| Timeline collection grows unbounded | High | Medium | Task 13 implements automatic retention cleanup (keep last 1000 items, configurable) |
|
||||
| Announce wraps a deleted/inaccessible object | Medium | Low | If `activity.getObject()` returns null or fails, skip storing the boost and log a warning. Don't crash the inbox handler. |
|
||||
| Remote actor lookup fails during profile view | Medium | Low | Show error message "Could not load profile — the server may be temporarily unavailable" with retry link. Don't crash the page. |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should there be a "Refresh timeline" button/action, or does it automatically show new items on page reload? → Decision: Automatic on reload for MVP; real-time updates (SSE/polling) deferred
|
||||
- Should the AP reader be the default landing page when navigating to `/admin/activitypub/`? → Decision: Yes, redirect `/admin/activitypub/` to `/admin/activitypub/reader` as the primary view. Dashboard remains accessible via sub-navigation within the reader layout. The top-level sidebar `get navigationItems()` returns "Reader" linking to `/activitypub/reader`.
|
||||
- What's the maximum number of timeline items to store before cleanup? → Decision: Keep last 1000 items; auto-delete older items on a weekly basis
|
||||
|
||||
### Deferred Ideas
|
||||
|
||||
- Real-time timeline updates via Server-Sent Events (SSE) or periodic polling
|
||||
- Lists feature (organizing follows into named groups with separate timelines)
|
||||
- Thread view (expanding full conversation thread from a reply)
|
||||
- Mastodon REST API compatibility layer for mobile clients
|
||||
- Push notifications for new mentions/replies
|
||||
- Image lightbox for gallery view
|
||||
- Infinite scroll instead of pagination
|
||||
- Timeline item search
|
||||
@@ -1,398 +0,0 @@
|
||||
# ActivityPub Deck Layout Implementation Plan
|
||||
|
||||
Created: 2026-02-27
|
||||
Status: VERIFIED
|
||||
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:** No — works directly on current branch
|
||||
|
||||
## Summary
|
||||
|
||||
**Goal:** Add a TweetDeck-style multi-column deck layout to the ActivityPub explore view. Users can favorite/bookmark instances (with local or federated scope) and see them as persistent columns in a deck view, each streaming its own public timeline. The same instance can appear twice with different scopes. Also includes responsive CSS fixes for input fields.
|
||||
|
||||
**Architecture:** The explore page gets a two-tab UI: "Search" (existing browse-by-search) and "Decks" (multi-column layout of favorited instances). Favorited instances are stored in a new `ap_decks` MongoDB collection. Each deck column is an independent Alpine.js component that fetches timelines via the existing `/api/explore` AJAX endpoint. Deck CRUD is handled via JSON API endpoints. A "favorite" button on the search view lets users save the current instance+scope as a deck.
|
||||
|
||||
**Tech Stack:** Alpine.js (client-side reactivity), Express routes (API), MongoDB (persistence), CSS Grid (responsive multi-column layout), existing Mastodon-compatible public timeline API.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- New `ap_decks` MongoDB collection for storing favorited instances
|
||||
- CRUD API endpoints for deck management (add, remove, list)
|
||||
- Two-tab explore page: "Search" tab (existing) and "Decks" tab (new)
|
||||
- Multi-column deck layout with CSS Grid, responsive wrapping
|
||||
- Each deck column loads its timeline via AJAX with infinite scroll
|
||||
- "Add to deck" button on the search view when browsing an instance
|
||||
- Visual badge for local vs federated scope on each column header
|
||||
- Remove deck button on each column header
|
||||
- Responsive CSS fix for `.ap-lookup__input` and `.ap-explore-form__input`
|
||||
- i18n strings for all new UI elements
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Drag-and-drop column reordering (complex, future enhancement)
|
||||
- Auto-refresh / live streaming of deck columns (future enhancement)
|
||||
- Cross-column interactions (liking/boosting from deck columns)
|
||||
- Custom column names (domain+scope is the label)
|
||||
- Deck columns for non-Mastodon-compatible instances
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Existing explore controller with `exploreApiController` for AJAX timeline loading (`lib/controllers/explore.js`)
|
||||
- Existing `apExploreScroll` Alpine.js component for infinite scroll (`assets/reader-infinite-scroll.js`)
|
||||
- Alpine.js loaded via CDN in `views/layouts/ap-reader.njk`
|
||||
- FediDB autocomplete already working on explore page
|
||||
|
||||
## Context for Implementer
|
||||
|
||||
> This section is critical for cross-session continuity.
|
||||
|
||||
- **Patterns to follow:**
|
||||
- Route registration: Follow the pattern in `index.js:234` — all deck routes go in the `routes` getter (authenticated admin routes, behind IndieAuth)
|
||||
- Controller factory pattern: All controllers are factory functions returning `(request, response, next)` — see `explore.js:119`
|
||||
- MongoDB collection access: `request.app.locals.application.collections.get("ap_decks")` or pass via factory closure
|
||||
- Alpine.js component registration: via `alpine:init` event, see `assets/reader-autocomplete.js:6`
|
||||
- Infinite scroll: Deck columns must implement their OWN scroll handler (NOT reuse `apExploreScroll` — see gotcha below)
|
||||
- CSS custom properties: Use Indiekit theme vars (`--color-*`, `--space-*`, `--border-*`) — see `assets/reader.css`
|
||||
|
||||
- **Conventions:**
|
||||
- All controllers are ESM modules with named exports
|
||||
- CSS class naming: `ap-<feature>__<element>--<modifier>` (BEM-like)
|
||||
- Template naming: `activitypub-<feature>.njk` with `ap-reader.njk` layout
|
||||
- i18n: All user-visible strings go in `locales/en.json` under `activitypub.reader.explore.deck.*`
|
||||
- Dates stored as ISO 8601 strings: always `new Date().toISOString()`, never `new Date()` (CRITICAL — see CLAUDE.md)
|
||||
|
||||
- **Key files the implementer must read first:**
|
||||
- `lib/controllers/explore.js` — Existing explore controller with timeline fetching, SSRF validation, Mastodon API mapping
|
||||
- `views/activitypub-explore.njk` — Current explore template
|
||||
- `assets/reader-infinite-scroll.js` — `apExploreScroll` Alpine component for explore infinite scroll
|
||||
- `assets/reader-autocomplete.js` — `apInstanceSearch` Alpine component for autocomplete
|
||||
- `index.js:226-238` — Route registration for explore endpoints (in the `routes` getter)
|
||||
- `index.js:862-878` — MongoDB collection registration
|
||||
|
||||
- **Gotchas:**
|
||||
- The explore API (`/api/explore`) already server-side renders card HTML via `request.app.render()` — deck columns can reuse this
|
||||
- Template name collisions: Use `ap-` prefix for all new templates (see CLAUDE.md gotcha #7)
|
||||
- Express 5 removed `redirect("back")` — always use explicit redirect paths
|
||||
- The `validateInstance()` function in `explore.js` is NOT currently exported — Task 2 must export it before importing in `decks.js`
|
||||
- Alpine components MUST load via `defer` script tags BEFORE the Alpine CDN script — `reader-decks.js` must be added before the Alpine CDN `<script>` in `ap-reader.njk`
|
||||
- **`apExploreScroll` CANNOT be reused for deck columns:** It hardcodes `document.getElementById("ap-explore-timeline")` at line 48 — with multiple deck columns, `getElementById` only finds the first one. Deck columns MUST implement their own scroll handler using `this.$refs` or `this.$el.querySelector()` to reference the column's own container.
|
||||
- **CSRF protection is required** on all deck CRUD endpoints. The codebase has `lib/csrf.js` with `getToken()` and `validateToken()`. The `exploreController` must pass `csrfToken: getToken(request.session)` to the template, and client-side `fetch()` calls must include the `X-CSRF-Token` header. Server-side endpoints must call `validateToken(request)` before processing.
|
||||
|
||||
- **Domain context:**
|
||||
- "Local" timeline = posts from users who have accounts on that instance
|
||||
- "Federated" timeline = all posts that instance's relay has seen from across the fediverse
|
||||
- Mastodon API: `GET /api/v1/timelines/public?local=true|false&limit=N&max_id=ID`
|
||||
- Not all instances support public timeline access (some return 401/422) — the FediDB instance-check endpoint already handles this
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
- **Start command:** Part of Indiekit — `npm start` or deployed via Cloudron
|
||||
- **Port:** 8080 (behind nginx on Cloudron)
|
||||
- **Deploy path:** Published to npm, installed in `indiekit-cloudron/Dockerfile`
|
||||
- **Health check:** Served via Indiekit's built-in health endpoint
|
||||
- **Restart procedure:** `cloudron restart --app rmendes.net` or bump version + `npm publish` + `cloudron build`
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.**
|
||||
|
||||
- [x] Task 1: Responsive CSS fix + deck collection setup
|
||||
- [x] Task 2: Deck CRUD API endpoints
|
||||
- [x] Task 3: Two-tab explore page layout
|
||||
- [x] Task 4: "Add to deck" button on search view
|
||||
- [x] Task 5: Deck column Alpine.js component
|
||||
- [x] Task 6: Multi-column deck view with responsive grid
|
||||
|
||||
**Total Tasks:** 6 | **Completed:** 6 | **Remaining:** 0
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Task 1: Responsive CSS Fix + Deck Collection Setup
|
||||
|
||||
**Objective:** Commit the pending responsive CSS changes and register the new `ap_decks` MongoDB collection with proper indexes.
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `assets/reader.css` (already has uncommitted changes — commit the existing diff as-is)
|
||||
- Modify: `index.js` (add `ap_decks` collection registration + index)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- The CSS diff changes `.ap-lookup__input` and `.ap-explore-form__input` from `flex: 1` to `width: 100%; box-sizing: border-box`, and also alphabetically reorders properties. Commit the existing diff without additional changes.
|
||||
- New collection `ap_decks` stores deck entries: `{ domain, scope, addedAt }`
|
||||
- `addedAt` MUST be stored as `new Date().toISOString()` per the date convention (never `new Date()`)
|
||||
- Compound unique index on `{ domain: 1, scope: 1 }` allows same instance with different scopes
|
||||
- Column order is determined by `addedAt` ascending (no separate position field — drag-and-drop reordering is out of scope)
|
||||
- Collection registration follows the pattern at `index.js:862-878`
|
||||
- Collection reference added to `this._collections` object at `index.js:882-903`
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] `ap_decks` collection is registered in `index.js` init method
|
||||
- [ ] `ap_decks` has compound unique index `{ domain: 1, scope: 1 }`
|
||||
- [ ] `ap_decks` is added to `this._collections` for controller access
|
||||
- [ ] Responsive CSS fix is included (commit existing diff as-is)
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `grep "ap_decks" index.js` — collection registered and referenced
|
||||
- Visual check: input fields span full width on explore and reader pages
|
||||
|
||||
### Task 2: Deck CRUD API Endpoints
|
||||
|
||||
**Objective:** Create API endpoints for managing deck entries: list all decks, add a deck, remove a deck. Export `validateInstance()` from `explore.js` for reuse.
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/controllers/explore.js` (export `validateInstance()`)
|
||||
- Create: `lib/controllers/decks.js`
|
||||
- Modify: `index.js` (import and register routes in the `routes` getter)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- First, export `validateInstance()` from `explore.js` by changing `function validateInstance` to `export function validateInstance`
|
||||
- Three endpoints, all registered in the `routes` getter (authenticated via IndieAuth):
|
||||
- `GET /admin/reader/api/decks` — returns all decks sorted by `addedAt` ascending
|
||||
- `POST /admin/reader/api/decks` — body: `{ domain, scope }`. Validates domain via `validateInstance()`. Returns the created deck.
|
||||
- `POST /admin/reader/api/decks/remove` — body: `{ domain, scope }`. Removes the deck entry. Returns `{ success: true }`. Uses POST instead of DELETE to avoid issues with request bodies being stripped by proxies/CDNs.
|
||||
- Follow the controller factory pattern from `explore.js:119` — each endpoint is a factory function returning `(req, res, next)`
|
||||
- Maximum 8 decks enforced: `POST /api/decks` returns 400 if user already has 8 or more
|
||||
- **CSRF protection:** Both `POST /api/decks` and `POST /api/decks/remove` must call `validateToken(request)` from `lib/csrf.js` before processing. Return 403 if invalid.
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] `validateInstance()` is exported from `explore.js` for reuse by deck endpoints
|
||||
- [ ] `GET /api/decks` returns JSON array of decks sorted by addedAt
|
||||
- [ ] `POST /api/decks` with `{ domain: "mastodon.social", scope: "local" }` creates a deck entry
|
||||
- [ ] `POST /api/decks` with invalid domain returns 400 error
|
||||
- [ ] `POST /api/decks` with duplicate domain+scope returns 409 conflict
|
||||
- [ ] `POST /api/decks` returns 400 if user already has 8 or more decks
|
||||
- [ ] `POST /api/decks/remove` with `{ domain, scope }` removes the entry
|
||||
- [ ] All endpoints are registered in the `routes` getter (behind IndieAuth)
|
||||
- [ ] All endpoints use `validateInstance()` for SSRF prevention
|
||||
- [ ] `POST /api/decks` and `POST /api/decks/remove` validate CSRF token via `validateToken(request)` from `lib/csrf.js`
|
||||
|
||||
**Verify:**
|
||||
|
||||
- `curl` commands against the running instance to test CRUD operations
|
||||
- `grep "api/decks" index.js` — routes registered in the `routes` getter
|
||||
|
||||
### Task 3: Two-Tab Explore Page Layout
|
||||
|
||||
**Objective:** Restructure the explore page with tab navigation: "Search" (existing functionality) and "Decks" (new deck view). Server-rendered tabs with URL parameter switching.
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `views/activitypub-explore.njk`
|
||||
- Modify: `lib/controllers/explore.js` (pass `decks` and `activeTab` to template)
|
||||
- Modify: `assets/reader.css` (tab styles)
|
||||
- Modify: `locales/en.json` (new i18n strings)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Tab switching via `?tab=search|decks` query parameter, default "search"
|
||||
- The `exploreController` fetches deck list from `ap_decks` and passes to template as `decks`
|
||||
- The `exploreController` must also pass `csrfToken: getToken(request.session)` to the template so Alpine.js components can include it in `X-CSRF-Token` headers on fetch calls
|
||||
- Tab CSS follows the existing notification tabs pattern (see `activitypub-notifications.njk` if available, or design from scratch using `ap-explore-tabs__*` class prefix)
|
||||
- The "Decks" tab content is a container that the Alpine.js deck components (Task 5-6) will populate
|
||||
- When `?tab=decks` and no decks exist, show an empty state message explaining how to add decks
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Explore page shows two tabs: "Search" and "Decks"
|
||||
- [ ] Clicking "Search" tab shows `?tab=search` with existing explore UI
|
||||
- [ ] Clicking "Decks" tab shows `?tab=decks` with deck container
|
||||
- [ ] Active tab is visually highlighted
|
||||
- [ ] "Decks" tab with no decks shows empty state message
|
||||
- [ ] All new strings are in `locales/en.json`
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Navigate to `/activitypub/admin/reader/explore` — see Search tab active by default
|
||||
- Navigate to `/activitypub/admin/reader/explore?tab=decks` — see Decks tab active
|
||||
- Check i18n strings present: `grep "deck" locales/en.json`
|
||||
|
||||
### Task 4: "Add to Deck" Button on Search View
|
||||
|
||||
**Objective:** Add a "favorite" / "Add to deck" button on the search results view that saves the current instance+scope as a deck column.
|
||||
|
||||
**Dependencies:** Task 2, Task 3
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `views/activitypub-explore.njk` (add star/favorite button)
|
||||
- Modify: `assets/reader.css` (button styles)
|
||||
- Create: `assets/reader-decks.js` (Alpine.js component for deck management)
|
||||
- Modify: `views/layouts/ap-reader.njk` (add script tag for reader-decks.js — MUST be placed BEFORE the Alpine CDN script, alongside the other component scripts)
|
||||
- Modify: `locales/en.json` (button labels)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- The button appears next to the "Browse" button when viewing an instance timeline (results are showing)
|
||||
- Alpine.js `apDeckToggle` component: checks if current instance+scope is already a deck, shows filled/empty star
|
||||
- On click: calls `POST /api/decks` or `POST /api/decks/remove` to toggle
|
||||
- **CSRF token:** All fetch calls must include `X-CSRF-Token` header with the token from the template (passed via a `data-csrf-token` attribute on the component's container, populated by `{{ csrfToken }}` from the server)
|
||||
- Visual feedback: star fills/empties, brief toast or inline feedback
|
||||
- **Max deck limit enforcement:** The component must know the current deck count. When 8 decks exist and the instance is not already favorited, the star button should be disabled with a tooltip explaining the limit. The template must pass the deck count (or max-reached boolean) so the Alpine component can check.
|
||||
- The new `reader-decks.js` file will hold both the deck toggle and the deck column components (Task 5)
|
||||
- The `<script defer>` tag in `ap-reader.njk` MUST be placed before the Alpine CDN `<script>` so the component is registered via `alpine:init` before Alpine initializes
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Star button appears when browsing an instance timeline on the Search tab
|
||||
- [ ] Clicking the star when not favorited calls `POST /api/decks` and fills the star
|
||||
- [ ] Clicking the star when already favorited calls `POST /api/decks/remove` and empties the star
|
||||
- [ ] Star state is correct on page load (pre-checked against existing decks)
|
||||
- [ ] Button has appropriate aria-label and title text
|
||||
- [ ] Fetch calls include `X-CSRF-Token` header with token from template
|
||||
- [ ] Star button is disabled with tooltip when 8 decks already exist (and current instance is not already favorited)
|
||||
- [ ] `reader-decks.js` script tag is placed before Alpine CDN script in `ap-reader.njk`
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Browse mastodon.social local timeline → star button visible
|
||||
- Click star → star fills, deck entry created (verify via `GET /api/decks`)
|
||||
- Reload page → star is still filled
|
||||
- Click star again → star empties, deck entry removed
|
||||
|
||||
### Task 5: Deck Column Alpine.js Component
|
||||
|
||||
**Objective:** Create the `apDeckColumn` Alpine.js component that loads a single instance's timeline into a scrollable column with infinite scroll.
|
||||
|
||||
**Dependencies:** Task 2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `assets/reader-decks.js` (add `apDeckColumn` component)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- Each column is an independent Alpine.js component initialized with `domain` and `scope` props
|
||||
- On init, fetches timeline from `GET /admin/reader/api/explore?instance={domain}&scope={scope}`
|
||||
- Response includes `{ html, maxId }` — the HTML is server-rendered card markup
|
||||
- Column maintains its own `maxId` for pagination, own `loading` and `done` states
|
||||
- **Own scroll handler (NOT `apExploreScroll`):** The `apDeckColumn` component MUST implement its own IntersectionObserver-based scroll handler. The existing `apExploreScroll` uses `document.getElementById("ap-explore-timeline")` which only finds the first element — it fundamentally cannot work with multiple columns. The deck column component should use `this.$refs.sentinel` or `this.$el.querySelector('.ap-deck-column__sentinel')` to observe within its own container.
|
||||
- Column header shows: instance domain, scope badge (Local/Federated), and a remove button
|
||||
- Remove button calls `POST /api/decks/remove` (with CSRF token in `X-CSRF-Token` header) then removes the column from DOM
|
||||
- Error handling: if instance is unreachable, show error message in column body with a "Retry" button that re-triggers the fetch
|
||||
- Loading state: show spinner/skeleton while first batch loads
|
||||
- **Staggered initial fetch:** Columns delay their initial fetch based on their index (column 0 = immediate, column 1 = 500ms, column 2 = 1000ms, etc.) to avoid thundering herd when many columns load simultaneously
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] `apDeckColumn` component fetches and renders timeline items from remote instance
|
||||
- [ ] Infinite scroll loads more items as user scrolls down in the column
|
||||
- [ ] Column header shows domain name and scope badge
|
||||
- [ ] Remove button removes deck from DB and removes column from DOM
|
||||
- [ ] Loading spinner shown during initial fetch
|
||||
- [ ] Error message shown if instance is unreachable, with a "Retry" button
|
||||
- [ ] Scroll handler uses `this.$refs` or `this.$el.querySelector()` (NOT `document.getElementById`)
|
||||
- [ ] Remove button sends CSRF token via `X-CSRF-Token` header
|
||||
- [ ] Columns stagger their initial fetch with 500ms delay per column index
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Add a deck for mastodon.social (local) → column loads timeline items
|
||||
- Scroll to bottom of column → more items load
|
||||
- Click remove → column disappears, `GET /api/decks` no longer includes it
|
||||
|
||||
### Task 6: Multi-Column Deck View with Responsive Grid
|
||||
|
||||
**Objective:** Build the deck view that renders all favorited instances as a multi-column layout using CSS Grid, with responsive behavior.
|
||||
|
||||
**Dependencies:** Task 3, Task 5
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `views/activitypub-explore.njk` (deck view section with column containers)
|
||||
- Modify: `assets/reader.css` (CSS Grid layout, responsive breakpoints)
|
||||
- Modify: `assets/reader-decks.js` (deck view initialization)
|
||||
- Modify: `locales/en.json` (empty states, column labels)
|
||||
|
||||
**Key Decisions / Notes:**
|
||||
|
||||
- CSS Grid layout: `grid-template-columns: repeat(auto-fill, minmax(360px, 1fr))`
|
||||
- Desktop: columns sit side-by-side (2-3 columns on wide screens)
|
||||
- Tablet: 2 columns
|
||||
- Mobile (<768px): single column, stacked vertically
|
||||
- Each column has a fixed max-height with internal scrolling (`overflow-y: auto`)
|
||||
- Column max-height: `calc(100vh - 200px)` (viewport minus header/tabs)
|
||||
- Scope badge styling: "Local" gets a blue badge, "Federated" gets a purple badge
|
||||
- Empty deck view: centered message with explanation and a link to the Search tab
|
||||
- Column order follows `addedAt` ascending from `ap_decks` (oldest first)
|
||||
- The deck view template renders column containers server-side (from `decks` data), but each column loads its content client-side via Alpine.js
|
||||
- Column containers have `x-data="apDeckColumn('domain', 'scope', 'mountPath', index)"` attributes (index used for stagger delay)
|
||||
|
||||
**Definition of Done:**
|
||||
|
||||
- [ ] Deck view renders all favorited instances as columns
|
||||
- [ ] Columns sit side-by-side on desktop (≥1024px)
|
||||
- [ ] Columns stack vertically on mobile (<768px)
|
||||
- [ ] Each column has its own scrollbar for long timelines
|
||||
- [ ] Scope badges show "Local" (blue) or "Federated" (purple) per column
|
||||
- [ ] Empty deck view shows helpful message with link to Search tab
|
||||
- [ ] Column order matches addedAt ascending in `ap_decks`
|
||||
|
||||
**Verify:**
|
||||
|
||||
- Add 3 decks → see 3 columns on desktop
|
||||
- Resize browser to mobile → columns stack
|
||||
- Each column scrolls independently
|
||||
- Empty decks view shows the empty state message
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests:** No automated test suite exists. Manual testing against real fediverse instances.
|
||||
- **Integration tests:** Test deck CRUD API endpoints via `curl`:
|
||||
- `POST /api/decks` with valid/invalid/duplicate data
|
||||
- `GET /api/decks` returns correct list
|
||||
- `POST /api/decks/remove` removes entries
|
||||
- **Manual verification:**
|
||||
1. Add 2-3 decks (mix of local/federated)
|
||||
2. Switch to Decks tab — see columns
|
||||
3. Scroll columns — infinite scroll works
|
||||
4. Remove a deck from column header — column disappears
|
||||
5. Add same instance with different scope — both columns appear
|
||||
6. Resize browser — responsive layout works
|
||||
7. Test on deployed Cloudron instance
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| ---- | ---------- | ------ | ---------- |
|
||||
| Multiple columns fetching simultaneously may slow page load | Medium | Medium | Stagger initial column fetches with 500ms delay per column index (column 0 immediate, column 1 at 500ms, etc.) |
|
||||
| Remote instances may block or rate-limit multiple simultaneous timeline requests | Low | Medium | Each column fetches independently with its own AbortController; timeout at 10s (existing FETCH_TIMEOUT_MS) |
|
||||
| Large number of deck columns may cause layout issues | Low | Low | Cap maximum decks at 8; `POST /api/decks` returns 400 if limit reached |
|
||||
| Instance timeline API format varies across Mastodon forks | Low | Medium | The existing `mapMastodonStatusToItem()` in `explore.js` already handles this; deck columns reuse same API |
|
||||
| CSS Grid not supported in very old browsers | Very Low | Low | CSS Grid has >97% browser support; fallback is single-column layout (natural Grid behavior) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None — requirements are clear from user description.
|
||||
|
||||
### Deferred Ideas
|
||||
|
||||
- Drag-and-drop column reordering
|
||||
- Auto-refresh / live streaming of deck columns (WebSocket or polling)
|
||||
- Deck column width customization
|
||||
- Cross-column interactions (like/boost directly from deck columns without opening post)
|
||||
- Deck sharing/export (export deck configuration)
|
||||
- Deck presets (pre-configured sets of popular instances)
|
||||
@@ -1,540 +0,0 @@
|
||||
# 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": "<p>IndieWeb enthusiast</p>",
|
||||
"icon": "https://rmendes.net/avatar.jpg",
|
||||
"image": "https://rmendes.net/header.jpg",
|
||||
"url": "https://rmendes.net/",
|
||||
"attachments": [
|
||||
{ "name": "Website", "value": "<a href=\"https://rmendes.net\">rmendes.net</a>" },
|
||||
{ "name": "GitHub", "value": "<a href=\"https://github.com/rmdes\">rmdes</a>" }
|
||||
],
|
||||
"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
|
||||
Reference in New Issue
Block a user