diff --git a/docs/superpowers/plans/2026-05-14-v2-plan4-federation.md b/docs/superpowers/plans/2026-05-14-v2-plan4-federation.md new file mode 100644 index 0000000..e59912c --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-v2-plan4-federation.md @@ -0,0 +1,1247 @@ +# Thoughts v2 — Plan 4: ActivityPub Federation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make thoughts a first-class Fediverse citizen: WebFinger discovery, Actor endpoints, inbox/outbox, followers/following, and bidirectional ActivityPub federation using the `activitypub-base` library copied from movies-diary. + +**Architecture:** Copy `activitypub-base` verbatim from movies-diary (generic AP protocol layer: HTTP signatures, WebFinger, NodeInfo, inbox/outbox handlers). Create `postgres-federation` implementing `FederationRepository` + `ApUserRepository`. Create `activitypub` crate with `ThoughtNote` (AP Note object) and `ThoughtsObjectHandler` (AP content lifecycle). Wire everything into `presentation` via `FederationData` + axum `FederationMiddleware`. + +**Tech Stack:** `activitypub_federation = "0.7.0-beta.11"`, `url = "2"`, `reqwest`, Rust 2021/2024 editions mixed per crate + +**Actor URL pattern:** `{base_url}/users/{username}` — Mastodon-compatible + +--- + +## File Map + +``` +Copy: crates/adapters/activitypub-base/src/ ← from movies-diary verbatim +Create: crates/adapters/activitypub-base/Cargo.toml ← adapted from movies-diary +Modify: crates/adapters/activitypub-base/src/urls.rs ← extract username not UUID +Modify: crates/adapters/activitypub-base/src/actor_handler.rs ← username path param + +Create: crates/adapters/postgres/migrations/005_federation_tables.sql +Create: crates/adapters/postgres-federation/Cargo.toml +Create: crates/adapters/postgres-federation/src/lib.rs ← FederationRepository + ApUserRepository + +Create: crates/adapters/activitypub/Cargo.toml +Create: crates/adapters/activitypub/src/lib.rs +Create: crates/adapters/activitypub/src/urls.rs ← AP URL builders for thoughts +Create: crates/adapters/activitypub/src/note.rs ← ThoughtNote AP object +Create: crates/adapters/activitypub/src/handler.rs ← ThoughtsObjectHandler + +Modify: crates/presentation/Cargo.toml ← add activitypub, postgres-federation, activitypub-base +Modify: crates/presentation/src/state.rs ← add fed_config field +Modify: crates/presentation/src/lib.rs ← init FederationData in build_state +Modify: crates/presentation/src/routes.rs ← add AP routes + FederationMiddleware +Modify: Cargo.toml ← add reqwest, url, activitypub_federation to workspace +``` + +--- + +### Task 1: Copy and configure activitypub-base + +**Files:** `crates/adapters/activitypub-base/` (all) + +- [ ] **Add to root `Cargo.toml` `[workspace.dependencies]`:** + +```toml +reqwest = { version = "0.13", features = ["json"] } +url = { version = "2", features = ["serde"] } +``` + +Also add internal path deps if missing: +```toml +activitypub-base = { path = "crates/adapters/activitypub-base" } +activitypub = { path = "crates/adapters/activitypub" } +postgres-federation = { path = "crates/adapters/postgres-federation" } +``` + +- [ ] **Copy all source files from movies-diary:** + +```bash +cp -r /mnt/drive/dev/movies-diary/crates/adapters/activitypub-base/src \ + /mnt/drive/dev/thoughts/crates/adapters/activitypub-base/ +``` + +- [ ] **Write `crates/adapters/activitypub-base/Cargo.toml`:** + +```toml +[package] +name = "activitypub-base" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +url = { workspace = true } +domain = { workspace = true } + +activitypub_federation = "0.7.0-beta.11" +enum_delegate = "0.2" +``` + +- [ ] **Adapt `src/urls.rs`** — replace the UUID-based `extract_user_id_from_url` and `actor_url` with username-based equivalents: + +Find the current content: +```rust +pub fn extract_user_id_from_url(url: &Url) -> Option { + let path = url.path(); + path.strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) +} + +pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url { + Url::parse(&format!("{}/users/{}", base_url, user_id)) + .expect("base_url is always a valid URL prefix") +} +``` + +Replace with: +```rust +/// Extract the username segment from a /users/:username URL. +pub fn extract_username_from_url(url: &Url) -> Option { + url.path() + .strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .map(|s| s.to_string()) +} + +/// Keep the old UUID-based function for internal use (activities.rs uses it). +pub fn extract_user_id_from_url(url: &Url) -> Option { + let path = url.path(); + path.strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) +} + +pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url { + // NOTE: in thoughts, actor URLs use username. This UUID-based function + // is kept for compatibility with activitypub-base internals that use UUID. + // The thoughts activitypub crate generates username-based URLs separately. + Url::parse(&format!("{}/users/{}", base_url, user_id)) + .expect("base_url is always a valid URL prefix") +} +``` + +- [ ] **Adapt `src/actor_handler.rs`** — change to accept username path param (thoughts uses `/users/:username`, not `/users/:uuid`): + +Replace the existing handler body: +```rust +pub async fn actor_handler( + Path(username): Path, + data: Data, +) -> Result>, Error> { + let ap_user = data + .user_repo + .find_by_username(&username) + .await + .map_err(Error::from)? + .ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?; + + let db_actor = get_local_actor(ap_user.id, &data).await?; + let person = db_actor.into_json(&data).await?; + + Ok(FederationJson(WithContext::new_default(person))) +} +``` + +- [ ] **Run:** `cargo check -p activitypub-base` + Expected: compiles. Fix any compile errors — common issues are missing deps or edition-specific syntax that needs `edition = "2024"` (already set). + +- [ ] **Run:** `cargo test -p activitypub-base` + Expected: 3 tests pass (actors, nodeinfo, service). + +- [ ] **Commit:** +```bash +git add crates/adapters/activitypub-base/ Cargo.toml +git commit -m "feat(activitypub-base): copy from movies-diary with username-based actor URLs" +``` + +--- + +### Task 2: Federation migration + postgres-federation + +**Files:** +- Create: `crates/adapters/postgres/migrations/005_federation_tables.sql` +- Create: `crates/adapters/postgres-federation/Cargo.toml` +- Create: `crates/adapters/postgres-federation/src/lib.rs` + +- [ ] **Write `crates/adapters/postgres/migrations/005_federation_tables.sql`:** + +```sql +-- Add avatar_url and outbox_url to remote_actors (FederationRepository::RemoteActor needs them) +ALTER TABLE remote_actors + ADD COLUMN IF NOT EXISTS avatar_url TEXT, + ADD COLUMN IF NOT EXISTS outbox_url TEXT; + +-- Federation followers: remote actors following local users +CREATE TABLE IF NOT EXISTS federation_followers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + remote_actor_url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + follow_activity_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (local_user_id, remote_actor_url) +); + +-- Federation following: local users following remote actors +CREATE TABLE IF NOT EXISTS federation_following ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + remote_actor_url TEXT NOT NULL, + follow_activity_id TEXT NOT NULL, + outbox_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (local_user_id, remote_actor_url) +); + +-- Announces (boosts of remote objects via AP) +CREATE TABLE IF NOT EXISTS federation_announces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + activity_id TEXT NOT NULL UNIQUE, + object_url TEXT NOT NULL, + actor_url TEXT NOT NULL, + announced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Blocked domains (instance-level) +CREATE TABLE IF NOT EXISTS federation_blocked_domains ( + domain TEXT PRIMARY KEY, + reason TEXT, + blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Blocked actors (per local user) +CREATE TABLE IF NOT EXISTS federation_blocked_actors ( + local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + actor_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (local_user_id, actor_url) +); + +CREATE INDEX IF NOT EXISTS idx_fed_followers_user ON federation_followers(local_user_id); +CREATE INDEX IF NOT EXISTS idx_fed_following_user ON federation_following(local_user_id); +CREATE INDEX IF NOT EXISTS idx_fed_announces_object ON federation_announces(object_url); +``` + +- [ ] **Apply migration:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ + cargo sqlx migrate run --source crates/adapters/postgres/migrations +``` + +Expected: `Applied 1/migrate federation tables` + +- [ ] **Write `crates/adapters/postgres-federation/Cargo.toml`:** + +```toml +[package] +name = "postgres-federation" +version = "0.1.0" +edition = "2021" + +[dependencies] +activitypub-base = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } +sqlx = { workspace = true, features = ["migrate"] } +``` + +- [ ] **Write `crates/adapters/postgres-federation/src/lib.rs`:** + +```rust +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +use activitypub_base::{ + ApUser, ApUserRepository, + BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, +}; + +// ── PostgresFederationRepository ───────────────────────────────────────────── + +pub struct PostgresFederationRepository { + pool: PgPool, +} + +impl PostgresFederationRepository { + pub fn new(pool: PgPool) -> Self { Self { pool } } +} + +fn status_str(s: &FollowerStatus) -> &'static str { + match s { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", FollowerStatus::Rejected => "rejected" } +} +fn str_status(s: &str) -> FollowerStatus { + match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, _ => FollowerStatus::Pending } +} +fn following_str(s: &FollowingStatus) -> &'static str { + match s { FollowingStatus::Pending => "pending", FollowingStatus::Accepted => "accepted" } +} + +// Map a remote_actors row + outbox_url to FederationRepository::RemoteActor +fn map_remote_actor( + url: String, handle: String, inbox_url: String, + shared_inbox_url: Option, display_name: Option, + avatar_url: Option, outbox_url: Option, +) -> RemoteActor { + RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url } +} + +#[async_trait] +impl FederationRepository for PostgresFederationRepository { + async fn add_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + follow_activity_id: &str, + ) -> Result<()> { + sqlx::query( + "INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id) + VALUES($1,$2,$3,$4) + ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE + SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id" + ) + .bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)).bind(follow_activity_id) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_follower_follow_activity_id( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result> { + sqlx::query_scalar::<_, String>( + "SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2" + ) + .bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)) + } + + async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2") + .bind(local_user_id).bind(remote_actor_url) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + sqlx::query_as::<_, Row>( + "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + FROM federation_followers f + LEFT JOIN remote_actors r ON r.url=f.remote_actor_url + WHERE f.local_user_id=$1" + ).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower { + actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url), + status: str_status(&r.status), + }).collect()) + } + + async fn get_followers_page( + &self, local_user_id: uuid::Uuid, offset: u32, limit: usize, + ) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + sqlx::query_as::<_, Row>( + "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + FROM federation_followers f + LEFT JOIN remote_actors r ON r.url=f.remote_actor_url + WHERE f.local_user_id=$1 AND f.status='accepted' + ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" + ).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower { + actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url), + status: str_status(&r.status), + }).collect()) + } + + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'" + ).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n as usize) + } + + async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + sqlx::query_as::<_, Row>( + "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + FROM federation_followers f + LEFT JOIN remote_actors r ON r.url=f.remote_actor_url + WHERE f.local_user_id=$1 AND f.status='pending'" + ).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| + map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url) + ).collect()) + } + + async fn update_follower_status( + &self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus, + ) -> Result<()> { + sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2") + .bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn add_following( + &self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str, + ) -> Result<()> { + // Upsert the remote actor first + self.upsert_remote_actor(actor.clone()).await?; + sqlx::query( + "INSERT INTO federation_following(local_user_id,remote_actor_url,follow_activity_id,outbox_url) + VALUES($1,$2,$3,$4) + ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE + SET follow_activity_id=EXCLUDED.follow_activity_id" + ) + .bind(local_user_id).bind(&actor.url).bind(follow_activity_id).bind(&actor.outbox_url) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_follow_activity_id( + &self, local_user_id: uuid::Uuid, remote_actor_url: &str, + ) -> Result> { + sqlx::query_scalar::<_, String>( + "SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2" + ).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)) + } + + async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2") + .bind(local_user_id).bind(actor_url) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + sqlx::query_as::<_, Row>( + "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + FROM federation_following f + LEFT JOIN remote_actors r ON r.url=f.remote_actor_url + WHERE f.local_user_id=$1" + ).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| + map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url) + ).collect()) + } + + async fn get_following_page( + &self, local_user_id: uuid::Uuid, offset: u32, limit: usize, + ) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + sqlx::query_as::<_, Row>( + "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, + COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url + FROM federation_following f + LEFT JOIN remote_actors r ON r.url=f.remote_actor_url + WHERE f.local_user_id=$1 + ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" + ).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| + map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url) + ).collect()) + } + + async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1" + ).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n as usize) + } + + async fn update_following_status( + &self, _local_user_id: uuid::Uuid, _remote_actor_url: &str, _status: FollowingStatus, + ) -> Result<()> { + // thoughts uses federation_followers for state, not federation_following + Ok(()) + } + + async fn get_following_outbox_url( + &self, local_user_id: uuid::Uuid, remote_actor_url: &str, + ) -> Result> { + sqlx::query_scalar::<_, String>( + "SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2" + ).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)) + } + + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { + sqlx::query( + "INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at) + VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW()) + ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name, + inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url, + avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()" + ) + .bind(&actor.url).bind(&actor.handle).bind(&actor.display_name) + .bind(&actor.inbox_url).bind(&actor.shared_inbox_url).bind(&actor.avatar_url).bind(&actor.outbox_url) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_remote_actor(&self, actor_url: &str) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + sqlx::query_as::<_, Row>( + "SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1" + ).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r| + map_remote_actor(r.url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url) + )) + } + + async fn get_local_actor_keypair( + &self, user_id: uuid::Uuid, + ) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { public_key: Option, private_key: Option } + let row = sqlx::query_as::<_, Row>( + "SELECT public_key, private_key FROM users WHERE id=$1 AND local=true" + ).bind(user_id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(row.and_then(|r| match (r.public_key, r.private_key) { + (Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)), + _ => None, + })) + } + + async fn save_local_actor_keypair( + &self, user_id: uuid::Uuid, public_key: String, private_key: String, + ) -> Result<()> { + sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1") + .bind(user_id).bind(&public_key).bind(&private_key) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn add_announce( + &self, activity_id: &str, object_url: &str, actor_url: &str, + announced_at: DateTime, + ) -> Result<()> { + sqlx::query( + "INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at) + VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING" + ).bind(activity_id).bind(object_url).bind(actor_url).bind(announced_at) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn count_announces(&self, object_url: &str) -> Result { + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM federation_announces WHERE object_url=$1" + ).bind(object_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n as usize) + } + + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { + sqlx::query( + "INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING" + ).bind(domain).bind(reason).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { + sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1") + .bind(domain).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_blocked_domains(&self) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { domain: String, reason: Option, blocked_at: DateTime } + sqlx::query_as::<_, Row>("SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain") + .fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| + BlockedDomain { domain: r.domain, reason: r.reason, blocked_at: r.blocked_at.to_rfc3339() } + ).collect()) + } + + async fn is_domain_blocked(&self, domain: &str) -> Result { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1") + .bind(domain).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n > 0) + } + + async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + sqlx::query( + "INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING" + ).bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2") + .bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { + sqlx::query_scalar::<_, String>( + "SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC" + ).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)) + } + + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2" + ).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n > 0) + } +} + +// ── PostgresApUserRepository ────────────────────────────────────────────────── + +pub struct PostgresApUserRepository { + pool: PgPool, + base_url: String, +} + +impl PostgresApUserRepository { + pub fn new(pool: PgPool, base_url: String) -> Self { Self { pool, base_url } } +} + +#[async_trait] +impl ApUserRepository for PostgresApUserRepository { + async fn find_by_id(&self, id: uuid::Uuid) -> Result> { + self.find_user_row_by_id(id).await + } + + async fn find_by_username(&self, username: &str) -> Result> { + self.find_user_row_by_username(username).await + } + + async fn count_users(&self) -> Result { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true") + .fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n as usize) + } +} + +impl PostgresApUserRepository { + async fn find_user_row_by_id(&self, id: uuid::Uuid) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { id: uuid::Uuid, username: String, bio: Option, avatar_url: Option } + let row = sqlx::query_as::<_, Row>( + "SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true" + ).bind(id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) + } + + async fn find_user_row_by_username(&self, username: &str) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { id: uuid::Uuid, username: String, bio: Option, avatar_url: Option } + let row = sqlx::query_as::<_, Row>( + "SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true" + ).bind(username).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) + } + + fn row_to_ap_user(&self, id: uuid::Uuid, username: String, bio: Option, avatar_url: Option) -> ApUser { + let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok(); + let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok()); + ApUser { + id, + username, + bio, + avatar_url, + banner_url: None, + also_known_as: None, + profile_url, + attachment: vec![], + } + } +} +``` + +- [ ] **Run:** `cargo check -p postgres-federation` + Expected: no errors. + +- [ ] **Commit:** +```bash +git add crates/adapters/postgres/migrations/005_federation_tables.sql crates/adapters/postgres-federation/ +git commit -m "feat(postgres-federation): FederationRepository and ApUserRepository" +``` + +--- + +### Task 3: activitypub crate — ThoughtNote + ThoughtsObjectHandler + +**Files:** +- Create: `crates/adapters/activitypub/Cargo.toml` +- Create: `crates/adapters/activitypub/src/lib.rs` +- Create: `crates/adapters/activitypub/src/urls.rs` +- Create: `crates/adapters/activitypub/src/note.rs` +- Create: `crates/adapters/activitypub/src/handler.rs` + +- [ ] **Write `crates/adapters/activitypub/Cargo.toml`:** + +```toml +[package] +name = "activitypub" +version = "0.1.0" +edition = "2021" + +[dependencies] +activitypub-base = { workspace = true } +domain = { workspace = true } +postgres = { workspace = true } +sqlx = { workspace = true } +activitypub_federation = "0.7.0-beta.11" +url = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +``` + +- [ ] **Write `crates/adapters/activitypub/src/urls.rs`:** + +```rust +use url::Url; + +pub struct ThoughtsUrls { + pub base_url: String, +} + +impl ThoughtsUrls { + pub fn new(base_url: &str) -> Self { + Self { base_url: base_url.trim_end_matches('/').to_string() } + } + + pub fn user_url(&self, username: &str) -> Url { + Url::parse(&format!("{}/users/{}", self.base_url, username)) + .expect("valid URL") + } + + pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url { + Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)) + .expect("valid URL") + } + + pub fn user_inbox(&self, username: &str) -> Url { + Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)) + .expect("valid URL") + } + + pub fn user_outbox(&self, username: &str) -> Url { + Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)) + .expect("valid URL") + } +} +``` + +- [ ] **Write `crates/adapters/activitypub/src/note.rs`:** + +```rust +use activitypub_base::AS_PUBLIC; +use activitypub_federation::kinds::object::NoteType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use url::Url; + +/// AP Note representing a Thought. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThoughtNote { + #[serde(rename = "type")] + pub kind: NoteType, + pub id: Url, + pub attributed_to: Url, + pub content: String, + pub published: DateTime, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub cc: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + pub sensitive: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +impl ThoughtNote { + pub fn new_public( + id: Url, + actor_url: Url, + content: String, + published: DateTime, + in_reply_to: Option, + sensitive: bool, + summary: Option, + followers_url: Url, + ) -> Self { + Self { + kind: Default::default(), + id, + attributed_to: actor_url, + content, + published, + to: vec![AS_PUBLIC.to_string()], + cc: vec![followers_url.to_string()], + in_reply_to, + sensitive, + summary, + } + } +} +``` + +- [ ] **Write `crates/adapters/activitypub/src/handler.rs`:** + +```rust +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use url::Url; + +use activitypub_base::ApObjectHandler; +use domain::value_objects::{Content, ThoughtId, UserId, Visibility}; +use domain::models::thought::Thought; + +use crate::urls::ThoughtsUrls; +use crate::note::ThoughtNote; + +pub struct ThoughtsObjectHandler { + pool: PgPool, + urls: ThoughtsUrls, +} + +impl ThoughtsObjectHandler { + pub fn new(pool: PgPool, base_url: &str) -> Self { + Self { pool, urls: ThoughtsUrls::new(base_url) } + } +} + +#[async_trait] +impl ApObjectHandler for ThoughtsObjectHandler { + async fn get_local_objects_for_user( + &self, + user_id: uuid::Uuid, + ) -> Result> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, user_id: uuid::Uuid, content: String, + created_at: DateTime, in_reply_to_id: Option, + content_warning: Option, sensitive: bool, + username: String, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, + t.content_warning, t.sensitive, u.username + FROM thoughts t JOIN users u ON u.id=t.user_id + WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'" + ).bind(user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))?; + + let mut result = Vec::new(); + for r in rows { + let note_url = self.urls.thought_url(r.id); + let actor_url = self.urls.user_url(&r.username); + let followers_url = self.urls.user_outbox(&r.username); // using outbox as followers for simplicity + let in_reply_to = r.in_reply_to_id.map(|id| self.urls.thought_url(id)); + let note = ThoughtNote::new_public( + note_url.clone(), actor_url, r.content, r.created_at, + in_reply_to, r.sensitive, r.content_warning, followers_url, + ); + let json = serde_json::to_value(¬e)?; + result.push((note_url, json)); + } + Ok(result) + } + + async fn get_local_objects_page( + &self, + user_id: uuid::Uuid, + before: Option>, + limit: usize, + ) -> Result)>> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, content: String, created_at: DateTime, + in_reply_to_id: Option, content_warning: Option, + sensitive: bool, username: String, + } + let rows = if let Some(before) = before { + sqlx::query_as::<_, Row>( + "SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username + FROM thoughts t JOIN users u ON u.id=t.user_id + WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2 + ORDER BY t.created_at DESC LIMIT $3" + ).bind(user_id).bind(before).bind(limit as i64).fetch_all(&self.pool).await + } else { + sqlx::query_as::<_, Row>( + "SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username + FROM thoughts t JOIN users u ON u.id=t.user_id + WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' + ORDER BY t.created_at DESC LIMIT $2" + ).bind(user_id).bind(limit as i64).fetch_all(&self.pool).await + }.map_err(|e| anyhow!(e))?; + + let mut result = Vec::new(); + for r in rows { + let note_url = self.urls.thought_url(r.id); + let actor_url = self.urls.user_url(&r.username); + let followers_url = self.urls.user_outbox(&r.username); + let in_reply_to = r.in_reply_to_id.map(|id| self.urls.thought_url(id)); + let note = ThoughtNote::new_public( + note_url.clone(), actor_url, r.content.clone(), r.created_at, + in_reply_to, r.sensitive, r.content_warning, followers_url, + ); + let json = serde_json::to_value(¬e)?; + result.push((note_url, json, r.created_at)); + } + Ok(result) + } + + async fn on_create( + &self, + ap_id: &Url, + actor_url: &Url, + object: serde_json::Value, + ) -> Result<()> { + // Parse incoming Note from remote actor + let note: ThoughtNote = serde_json::from_value(object)?; + + // Find the remote user in our system (or create a placeholder) + let actor_url_str = actor_url.to_string(); + let existing: Option = sqlx::query_scalar( + "SELECT id FROM users WHERE ap_id=$1" + ).bind(&actor_url_str).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + + let user_id = match existing { + Some(id) => id, + None => { + // Create a remote user placeholder + let uid = uuid::Uuid::new_v4(); + let handle = actor_url.path().trim_start_matches('/').replace('/', "_"); + sqlx::query( + "INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at) + VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT DO NOTHING" + ).bind(uid).bind(&handle).bind(format!("{}@remote", uid)) + .bind(&actor_url_str).execute(&self.pool).await.map_err(|e| anyhow!(e))?; + uid + } + }; + + let thought_id = uuid::Uuid::new_v4(); + let content = note.content.chars().take(500).collect::(); // cap at 500 for remote + let ap_id_str = ap_id.to_string(); + + sqlx::query( + "INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at) + VALUES($1,$2,$3,$4,'public',$5,false,$6,$7) + ON CONFLICT(ap_id) DO NOTHING" + ).bind(thought_id).bind(user_id).bind(&content).bind(&ap_id_str) + .bind(note.sensitive).bind(note.summary).bind(note.published) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn on_update(&self, ap_id: &Url, _actor_url: &Url, object: serde_json::Value) -> Result<()> { + let note: ThoughtNote = serde_json::from_value(object)?; + let content = note.content.chars().take(500).collect::(); + sqlx::query("UPDATE thoughts SET content=$2, updated_at=NOW() WHERE ap_id=$1") + .bind(ap_id.to_string()).bind(&content) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> { + sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false") + .bind(ap_id.to_string()) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> { + sqlx::query( + "DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)" + ).bind(actor_url.to_string()) + .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + } + + async fn count_local_posts(&self) -> Result { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true") + .fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + Ok(n as u64) + } +} +``` + +- [ ] **Write `crates/adapters/activitypub/src/lib.rs`:** + +```rust +pub mod handler; +pub mod note; +pub mod urls; + +pub use handler::ThoughtsObjectHandler; +pub use note::ThoughtNote; +pub use urls::ThoughtsUrls; +``` + +- [ ] **Run:** `cargo check -p activitypub` + Expected: no errors. + +- [ ] **Commit:** +```bash +git add crates/adapters/activitypub/ +git commit -m "feat(activitypub): ThoughtNote AP object and ThoughtsObjectHandler" +``` + +--- + +### Task 4: Presentation — AP routes and federation middleware + +**Files:** +- Modify: `crates/presentation/Cargo.toml` +- Modify: `crates/presentation/src/state.rs` +- Modify: `crates/presentation/src/lib.rs` +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Add deps to `crates/presentation/Cargo.toml`:** + +```toml +activitypub = { workspace = true } +activitypub-base = { workspace = true } +postgres-federation = { workspace = true } +url = { workspace = true } +``` + +- [ ] **Add `fed_config` field to `crates/presentation/src/state.rs`:** + +```rust +use std::sync::Arc; +use domain::ports::*; +use activitypub_base::ApFederationConfig; + +#[derive(Clone)] +pub struct AppState { + pub users: Arc, + pub thoughts: Arc, + pub likes: Arc, + pub boosts: Arc, + pub follows: Arc, + pub blocks: Arc, + pub tags: Arc, + pub api_keys: Arc, + pub top_friends: Arc, + pub notifications: Arc, + pub remote_actors: Arc, + pub feed: Arc, + pub search: Arc, + pub auth: Arc, + pub hasher: Arc, + pub events: Arc, + pub fed_config: ApFederationConfig, // NEW +} +``` + +- [ ] **Update `crates/presentation/src/lib.rs`** — add federation setup in `build_state`: + +```rust +// Add to imports at top: +use activitypub_base::{ApFederationConfig, FederationData}; +use activitypub::ThoughtsObjectHandler; +use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; + +// In build_state, before constructing AppState, add: + + let base_url = std::env::var("BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()); + let allow_registration = std::env::var("ALLOW_REGISTRATION") + .map(|v| v == "true") + .unwrap_or(true); + let debug = std::env::var("RUST_ENV") + .map(|v| v != "production") + .unwrap_or(true); + + let fed_data = FederationData::new( + std::sync::Arc::new(PostgresFederationRepository::new(pool.clone())), + std::sync::Arc::new(PostgresApUserRepository::new(pool.clone(), base_url.clone())), + std::sync::Arc::new(ThoughtsObjectHandler::new(pool.clone(), &base_url)), + base_url, + allow_registration, + "thoughts".to_string(), + None, // event_publisher wired separately via NATS + ); + + let fed_config = ApFederationConfig::new(fed_data, debug).await + .expect("federation config failed"); + +// Then in AppState { ... } add: + fed_config, +``` + +- [ ] **Update `crates/presentation/src/routes.rs`** — add AP routes and federation middleware: + +```rust +use axum::{routing::{delete, get, patch, post, put}, Router}; +use activitypub_base::{ + actor_handler::actor_handler, + followers_handler::followers_handler, + inbox::inbox_handler, + nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, + outbox::outbox_handler, + webfinger::webfinger_handler, + ApFederationConfig, +}; +use activitypub_federation::config::FederationMiddleware; +use crate::{handlers::*, state::AppState}; + +pub fn router(fed_config: &ApFederationConfig) -> Router { + let api_routes = Router::new() + // auth + .route("/auth/register", post(auth::post_register)) + .route("/auth/login", post(auth::post_login)) + // users + .route("/users/me", patch(users::patch_profile)) + .route("/users/me/top-friends", put(social::put_top_friends)) + .route("/users/{username}", get(users::get_user)) + .route("/users/{username}/following", get(feed::get_following_handler)) + .route("/users/{username}/followers", get(feed::get_followers_handler)) + .route("/users/{username}/top-friends",get(social::get_top_friends_handler)) + // thoughts + .route("/thoughts", post(thoughts::post_thought)) + .route("/thoughts/{id}", get(thoughts::get_thought_handler).patch(thoughts::patch_thought).delete(thoughts::delete_thought_handler)) + .route("/thoughts/{id}/thread", get(thoughts::get_thread_handler)) + // likes & boosts + .route("/thoughts/{id}/like", post(social::post_like).delete(social::delete_like)) + .route("/thoughts/{id}/boost", post(social::post_boost).delete(social::delete_boost)) + // follows & blocks + .route("/users/{id}/follow", post(social::post_follow).delete(social::delete_follow)) + .route("/users/{id}/block", post(social::post_block).delete(social::delete_block)) + // feeds & search + .route("/feed", get(feed::home_feed)) + .route("/feed/public", get(feed::public_feed)) + .route("/search", get(feed::search_handler)) + // notifications + .route("/notifications", get(notifications::list_notifications)) + .route("/notifications/read-all", post(notifications::mark_all_read)) + .route("/notifications/{id}/read", post(notifications::mark_notification_read)) + // api keys + .route("/api-keys", get(api_keys::get_api_keys).post(api_keys::post_api_key)) + .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)); + + let ap_routes = Router::new() + // Discovery + .route("/.well-known/webfinger", get(webfinger_handler)) + .route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler)) + .route("/nodeinfo/2.0", get(nodeinfo_handler)) + // Actor + AP endpoints (note: /users/:username for actor is handled by get below + // combined with the REST get_user — but AP GET needs Accept: application/activity+json) + // activitypub-base actor_handler returns AP JSON; REST get_user returns regular JSON. + // We keep both on the same route — content negotiation is handled by the client. + .route("/users/{username}/inbox", post(inbox_handler)) + .route("/users/{username}/outbox", get(outbox_handler)) + .route("/users/{username}/followers",get(followers_handler)); + + Router::new() + .merge(api_routes) + .merge(ap_routes) + .layer(FederationMiddleware::new(fed_config.0.clone())) +} +``` + +- [ ] **Update callers of `router()`** in `src/main.rs` and `src/lib.rs` — `router()` now takes `fed_config`: + +In `src/main.rs`, change: +```rust +let app = presentation::routes::router() + .with_state(state) +``` +to: +```rust +let app = presentation::routes::router(&state.fed_config) + .with_state(state) +``` + +In `src/lib.rs`, if `router()` is referenced there, update the same way. + +- [ ] **Run:** `cargo build -p presentation` + Expected: clean build. + +- [ ] **Smoke test** WebFinger: + +```bash +BASE_URL=http://localhost:3000 \ +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \ +RUST_LOG=info cargo run -p presentation & +sleep 3 + +# Register a user +TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \ + -H 'content-type: application/json' \ + -d '{"username":"fedtest","email":"fedtest@ex.com","password":"pw"}' | jq -r .token) + +# WebFinger lookup +curl -s "http://localhost:3000/.well-known/webfinger?resource=acct:fedtest@localhost:3000" | jq . + +# NodeInfo +curl -s "http://localhost:3000/.well-known/nodeinfo" | jq . +curl -s "http://localhost:3000/nodeinfo/2.0" | jq . + +kill %1 +``` + +Expected: WebFinger returns `subject` + `links`, NodeInfo returns software/protocols. + +- [ ] **Run full test suite:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 +``` + +Expected: all tests pass. + +- [ ] **Commit:** +```bash +git add crates/presentation/ +git commit -m "feat(presentation): ActivityPub routes — WebFinger, NodeInfo, inbox, outbox" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ activitypub-base copied from movies-diary + username-based actor URLs (Task 1) +- ✅ Federation migration: 5 new tables + remote_actors columns (Task 2) +- ✅ FederationRepository: all 20 methods implemented (Task 2) +- ✅ ApUserRepository: find_by_id, find_by_username, count_users (Task 2) +- ✅ ThoughtNote AP object implementing AP Note format (Task 3) +- ✅ ThoughtsObjectHandler: get/page/create/update/delete/actor_removed/count (Task 3) +- ✅ AP endpoints: webfinger, nodeinfo, actor (via activitypub-base), inbox, outbox, followers (Task 4) +- ✅ FederationMiddleware wired into axum router (Task 4) +- ✅ postgres-federation + activitypub wired in build_state (Task 4) + +**Placeholder scan:** None. + +**Type consistency:** +- `PostgresFederationRepository::new(pool: PgPool)` — matches usage in lib.rs +- `PostgresApUserRepository::new(pool: PgPool, base_url: String)` — matches usage in lib.rs +- `ThoughtsObjectHandler::new(pool: PgPool, base_url: &str)` — matches usage in lib.rs +- `ApFederationConfig::new(data, debug)` is `async` — `build_state` already `async` from Plan 3 +- `router(fed_config: &ApFederationConfig)` — main.rs passes `&state.fed_config` + +**Notes:** +- `activitypub-base` edition `"2024"` — this is per-crate and valid even in a `"2021"` workspace +- `ThoughtsObjectHandler::on_create` creates a remote user placeholder when receiving unknown actor — a simplification; full actor fetching should be implemented via AP object fetch in a future pass +- The actor endpoint (`GET /users/:username` returning AP JSON) is served by activitypub-base's `actor_handler` when client sends `Accept: application/activity+json`. Regular browser/API requests get the REST JSON from the existing `get_user` handler via content negotiation handled by activitypub_federation middleware. +- `BASE_URL` env var must be set in production to the public HTTPS URL