From 4cd94b3c7f4df1e2491942a09ee6a80c01c86fef Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 01:25:16 +0200 Subject: [PATCH] clean up --- Cargo.lock | 1 + .../2026-05-14-activitypub-repository-port.md | 639 --- .../plans/2026-05-14-api-cleanup.md | 1054 ----- .../plans/2026-05-14-audit-gap-fixes.md | 360 -- .../plans/2026-05-14-bootstrap-factory.md | 431 -- .../2026-05-14-event-publisher-refactor.md | 408 -- .../2026-05-14-event-transport-rename.md | 483 --- .../plans/2026-05-14-federation-follow-ups.md | 350 -- .../plans/2026-05-14-federation-handler.md | 1161 ------ .../plans/2026-05-14-merge-readiness.md | 562 --- .../plans/2026-05-14-openapi-docs.md | 822 ---- .../plans/2026-05-14-remote-actor-profile.md | 1288 ------ .../2026-05-14-remote-actor-search-follow.md | 917 ----- .../plans/2026-05-14-v1-parity-gaps.md | 246 -- .../plans/2026-05-14-v2-plan1-core.md | 3529 ----------------- .../plans/2026-05-14-v2-plan2-search.md | 707 ---- .../plans/2026-05-14-v2-plan3-events.md | 996 ----- .../plans/2026-05-14-v2-plan4-federation.md | 1247 ------ .../plans/2026-05-15-actor-connections.md | 1205 ------ .../specs/2026-05-14-api-cleanup-design.md | 118 - .../2026-05-14-remote-actor-profile-design.md | 300 -- ...05-14-remote-actor-search-follow-design.md | 81 - .../specs/2026-05-14-v2-rewrite-design.md | 285 -- .../2026-05-15-actor-connections-design.md | 213 - 24 files changed, 1 insertion(+), 17402 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-14-activitypub-repository-port.md delete mode 100644 docs/superpowers/plans/2026-05-14-api-cleanup.md delete mode 100644 docs/superpowers/plans/2026-05-14-audit-gap-fixes.md delete mode 100644 docs/superpowers/plans/2026-05-14-bootstrap-factory.md delete mode 100644 docs/superpowers/plans/2026-05-14-event-publisher-refactor.md delete mode 100644 docs/superpowers/plans/2026-05-14-event-transport-rename.md delete mode 100644 docs/superpowers/plans/2026-05-14-federation-follow-ups.md delete mode 100644 docs/superpowers/plans/2026-05-14-federation-handler.md delete mode 100644 docs/superpowers/plans/2026-05-14-merge-readiness.md delete mode 100644 docs/superpowers/plans/2026-05-14-openapi-docs.md delete mode 100644 docs/superpowers/plans/2026-05-14-remote-actor-profile.md delete mode 100644 docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md delete mode 100644 docs/superpowers/plans/2026-05-14-v1-parity-gaps.md delete mode 100644 docs/superpowers/plans/2026-05-14-v2-plan1-core.md delete mode 100644 docs/superpowers/plans/2026-05-14-v2-plan2-search.md delete mode 100644 docs/superpowers/plans/2026-05-14-v2-plan3-events.md delete mode 100644 docs/superpowers/plans/2026-05-14-v2-plan4-federation.md delete mode 100644 docs/superpowers/plans/2026-05-15-actor-connections.md delete mode 100644 docs/superpowers/specs/2026-05-14-api-cleanup-design.md delete mode 100644 docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md delete mode 100644 docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md delete mode 100644 docs/superpowers/specs/2026-05-14-v2-rewrite-design.md delete mode 100644 docs/superpowers/specs/2026-05-15-actor-connections-design.md diff --git a/Cargo.lock b/Cargo.lock index 6b49f6a..21fbcec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,7 @@ dependencies = [ "chrono", "domain", "enum_delegate", + "futures", "reqwest", "serde", "serde_json", diff --git a/docs/superpowers/plans/2026-05-14-activitypub-repository-port.md b/docs/superpowers/plans/2026-05-14-activitypub-repository-port.md deleted file mode 100644 index 8725854..0000000 --- a/docs/superpowers/plans/2026-05-14-activitypub-repository-port.md +++ /dev/null @@ -1,639 +0,0 @@ -# ActivityPubRepository Port Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Eliminate the `activitypub` → `postgres` dependency violation by extracting an `ActivityPubRepository` port into domain and implementing it in the postgres adapter. - -**Architecture:** `ActivityPubRepository` (9 methods, federation vocabulary) is added to `domain/src/ports.rs`. `PgActivityPubRepository` in `postgres/src/activitypub.rs` implements it with all the SQL that currently lives in `activitypub/src/handler.rs`. `ThoughtsObjectHandler` drops its `PgPool` and receives `Arc` instead. The dependency chain becomes `activitypub → domain` only; `postgres` drops off the `activitypub` Cargo.toml entirely. - -**Tech Stack:** Rust, sqlx 0.8, async-trait, existing domain value objects - ---- - -## File Map - -``` -Modify: crates/domain/src/ports.rs ← add OutboxEntry struct + ActivityPubRepository trait -Modify: crates/domain/src/testing.rs ← add TestStore impl ActivityPubRepository -Create: crates/adapters/postgres/src/activitypub.rs ← PgActivityPubRepository (all 9 methods) -Modify: crates/adapters/postgres/src/lib.rs ← pub mod activitypub -Modify: crates/adapters/activitypub/src/handler.rs ← replace PgPool with Arc -Modify: crates/adapters/activitypub/Cargo.toml ← remove postgres + sqlx deps -Modify: crates/presentation/src/lib.rs ← wire PgActivityPubRepository into ThoughtsObjectHandler -``` - ---- - -### Task 1: Domain — OutboxEntry + ActivityPubRepository trait - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/testing.rs` - -- [ ] **Write the failing test** — add to bottom of `crates/domain/src/testing.rs` inside the existing `#[cfg(any(test, feature = "test-helpers"))]` scope: - -```rust -#[cfg(test)] -mod ap_repo_tests { - use super::*; - use crate::models::thought::{Thought, Visibility}; - use crate::value_objects::*; - - #[tokio::test] - async fn test_store_outbox_returns_empty() { - let store = TestStore::default(); - let result = store.outbox_entries_for_actor(&UserId::new()).await.unwrap(); - assert!(result.is_empty()); - } - - #[tokio::test] - async fn test_store_intern_creates_placeholder() { - let store = TestStore::default(); - let url = url::Url::parse("https://example.com/users/alice").unwrap(); - let id1 = store.intern_remote_actor(&url).await.unwrap(); - let id2 = store.intern_remote_actor(&url).await.unwrap(); - assert_eq!(id1, id2, "intern must be idempotent"); - } -} -``` - -- [ ] **Run:** `cargo test -p domain` — Expected: FAIL (ActivityPubRepository not defined). - -- [ ] **Add `OutboxEntry` and `ActivityPubRepository` to `crates/domain/src/ports.rs`** — append after the `SearchPort` trait: - -```rust -/// A local thought ready for AP serialization, with the author's username -/// pre-joined so the handler can build AP URLs without a second query. -#[derive(Debug, Clone)] -pub struct OutboxEntry { - pub thought: crate::models::thought::Thought, - pub author_username: Username, -} - -#[async_trait] -pub trait ActivityPubRepository: Send + Sync { - // ── Outbox (local → remote) ────────────────────────────────────── - - /// All public local thoughts for this actor. Used for outbox totals - /// and full-collection delivery. - async fn outbox_entries_for_actor( - &self, - user_id: &UserId, - ) -> Result, DomainError>; - - /// Cursor page of public local thoughts, newest-first, stopping before - /// `before`. Used for OrderedCollectionPage responses. - async fn outbox_page_for_actor( - &self, - user_id: &UserId, - before: Option>, - limit: usize, - ) -> Result, DomainError>; - - // ── Remote actor resolution ────────────────────────────────────── - - /// Find the local UserId for a remote actor by its AP URL. - async fn find_remote_actor_id( - &self, - actor_ap_url: &url::Url, - ) -> Result, DomainError>; - - /// Ensure a remote actor placeholder exists; create one if absent. - /// Idempotent — safe to call multiple times with the same URL. - async fn intern_remote_actor( - &self, - actor_ap_url: &url::Url, - ) -> Result; - - // ── Inbox processing (remote → local) ─────────────────────────── - - /// Persist an incoming remote Note. Idempotent on ap_id. - async fn accept_note( - &self, - ap_id: &url::Url, - author_id: &UserId, - content: &str, - published: chrono::DateTime, - sensitive: bool, - content_warning: Option, - ) -> Result<(), DomainError>; - - /// Apply an Update to a previously accepted remote Note. - async fn apply_note_update( - &self, - ap_id: &url::Url, - new_content: &str, - ) -> Result<(), DomainError>; - - /// Remove a specific remote Note (Delete activity). Only touches - /// remotely-originated thoughts. - async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>; - - /// Remove all Notes from a remote actor (actor-level Delete/Tombstone). - async fn retract_actor_notes( - &self, - actor_ap_url: &url::Url, - ) -> Result<(), DomainError>; - - // ── Node-level stats ───────────────────────────────────────────── - - /// Total locally-authored thought count for NodeInfo responses. - async fn count_local_notes(&self) -> Result; -} -``` - -The imports already present in `ports.rs` cover `DomainError`, `UserId`, `Username`, `async_trait`. The `url::Url` and `chrono::DateTime` types need to be in scope — add these use statements at the top of `ports.rs` if not already present: - -```rust -use chrono::{DateTime, Utc}; -use url::Url; -``` - -Note: `url` and `chrono` are already in `domain/Cargo.toml`. No dep changes needed. - -- [ ] **Add `TestStore impl ActivityPubRepository`** in `crates/domain/src/testing.rs` — append after `impl SearchPort for TestStore`: - -```rust -#[async_trait] impl ActivityPubRepository for TestStore { - async fn outbox_entries_for_actor(&self, _uid: &UserId) -> Result, DomainError> { - Ok(vec![]) - } - async fn outbox_page_for_actor(&self, _uid: &UserId, _before: Option>, _limit: usize) -> Result, DomainError> { - Ok(vec![]) - } - async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result, DomainError> { - let url = actor_ap_url.to_string(); - Ok(self.users.lock().unwrap().iter().find(|u| u.ap_id.as_deref() == Some(&url)).map(|u| u.id.clone())) - } - async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result { - if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? { - return Ok(uid); - } - let uid = UserId::new(); - let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_"); - let user = crate::models::user::User { - id: uid.clone(), - username: Username::from_trusted(handle.clone()), - email: Email::from_trusted(format!("{}@remote", uid)), - password_hash: PasswordHash("".into()), - display_name: None, bio: None, avatar_url: None, header_url: None, - custom_css: None, local: false, - ap_id: Some(actor_ap_url.to_string()), - inbox_url: None, public_key: None, private_key: None, - created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), - }; - self.users.lock().unwrap().push(user); - Ok(uid) - } - async fn accept_note(&self, _ap_id: &url::Url, _author_id: &UserId, _content: &str, _published: chrono::DateTime, _sensitive: bool, _content_warning: Option) -> Result<(), DomainError> { - Ok(()) - } - async fn apply_note_update(&self, _ap_id: &url::Url, _new_content: &str) -> Result<(), DomainError> { Ok(()) } - async fn retract_note(&self, _ap_id: &url::Url) -> Result<(), DomainError> { Ok(()) } - async fn retract_actor_notes(&self, _actor_ap_url: &url::Url) -> Result<(), DomainError> { Ok(()) } - async fn count_local_notes(&self) -> Result { - Ok(self.thoughts.lock().unwrap().iter().filter(|t| t.local).count() as u64) - } -} -``` - -- [ ] **Run:** `cargo test -p domain` — Expected: all tests pass including 2 new ap_repo tests. - -- [ ] **Commit:** -```bash -git add crates/domain/ -git commit -m "feat(domain): ActivityPubRepository port with federation vocabulary" -``` - ---- - -### Task 2: Postgres — PgActivityPubRepository - -**Files:** -- Create: `crates/adapters/postgres/src/activitypub.rs` -- Modify: `crates/adapters/postgres/src/lib.rs` - -- [ ] **Write integration tests** at the bottom of the new `crates/adapters/postgres/src/activitypub.rs` (create the file with tests first): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::ports::ActivityPubRepository; - - #[sqlx::test(migrations = "./migrations")] - async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool); - let url = url::Url::parse("https://mastodon.social/users/alice").unwrap(); - let id1 = repo.intern_remote_actor(&url).await.unwrap(); - let id2 = repo.intern_remote_actor(&url).await.unwrap(); - assert_eq!(id1, id2); - } - - #[sqlx::test(migrations = "./migrations")] - async fn accept_and_retract_note(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool); - let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap(); - let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap(); - let author = repo.intern_remote_actor(&actor_url).await.unwrap(); - repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None) - .await.unwrap(); - repo.retract_note(&ap_id).await.unwrap(); - } - - #[sqlx::test(migrations = "./migrations")] - async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool); - assert_eq!(repo.count_local_notes().await.unwrap(), 0); - } -} -``` - -- [ ] **Run:** `cargo test -p postgres activitypub` — Expected: FAIL (module does not exist). - -- [ ] **Write `crates/adapters/postgres/src/activitypub.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use url::Url; - -use domain::{errors::DomainError, ports::{ActivityPubRepository, OutboxEntry}, value_objects::{Content, ThoughtId, UserId, Username}}; -use domain::models::thought::{Thought, Visibility}; - -pub struct PgActivityPubRepository { pool: PgPool } -impl PgActivityPubRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl ActivityPubRepository for PgActivityPubRepository { - async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result, DomainError> { - #[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, updated_at: Option> } - 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, t.updated_at - 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" - ) - .bind(user_id.as_uuid()) - .fetch_all(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|rows| rows.into_iter().map(|r| OutboxEntry { - thought: Thought { - id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), - content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), - in_reply_to_url: None, ap_id: None, visibility: Visibility::Public, - content_warning: r.content_warning, sensitive: r.sensitive, local: true, - created_at: r.created_at, updated_at: r.updated_at, - }, - author_username: Username::from_trusted(r.username), - }).collect()) - } - - async fn outbox_page_for_actor(&self, user_id: &UserId, before: Option>, limit: usize) -> Result, DomainError> { - #[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, updated_at: Option> } - let rows = if let Some(before) = before { - 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, t.updated_at - 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.as_uuid()).bind(before).bind(limit as i64).fetch_all(&self.pool).await - } else { - 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, t.updated_at - 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.as_uuid()).bind(limit as i64).fetch_all(&self.pool).await - }.map_err(|e| DomainError::Internal(e.to_string()))?; - - Ok(rows.into_iter().map(|r| OutboxEntry { - thought: Thought { - id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), - content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), - in_reply_to_url: None, ap_id: None, visibility: Visibility::Public, - content_warning: r.content_warning, sensitive: r.sensitive, local: true, - created_at: r.created_at, updated_at: r.updated_at, - }, - author_username: Username::from_trusted(r.username), - }).collect()) - } - - async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result, DomainError> { - sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1") - .bind(actor_ap_url.as_str()) - .fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(UserId::from_uuid)) - } - - async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result { - // Fast path - if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? { - return Ok(id); - } - let new_id = uuid::Uuid::new_v4(); - let handle = actor_ap_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(ap_id) DO NOTHING" - ) - .bind(new_id).bind(&handle).bind(format!("{}@remote", new_id)).bind(actor_ap_url.as_str()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - // Re-fetch to get whichever id won the race - self.find_remote_actor_id(actor_ap_url).await? - .ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into())) - } - - async fn accept_note(&self, ap_id: &Url, author_id: &UserId, content: &str, published: DateTime, sensitive: bool, content_warning: Option) -> Result<(), DomainError> { - let capped: String = content.chars().take(500).collect(); - 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(uuid::Uuid::new_v4()).bind(author_id.as_uuid()).bind(&capped) - .bind(ap_id.as_str()).bind(sensitive).bind(content_warning).bind(published) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> { - let capped: String = new_content.chars().take(500).collect(); - sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false") - .bind(ap_id.as_str()).bind(&capped) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> { - sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false") - .bind(ap_id.as_str()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn retract_actor_notes(&self, actor_ap_url: &Url) -> Result<(), DomainError> { - sqlx::query( - "DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)" - ) - .bind(actor_ap_url.as_str()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn count_local_notes(&self) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true") - .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(n as u64) - } -} -``` - -- [ ] **Add `pub mod activitypub;`** to `crates/adapters/postgres/src/lib.rs` — append alongside the other module declarations. - -- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres activitypub` - Expected: 3 tests pass. - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/src/activitypub.rs crates/adapters/postgres/src/lib.rs -git commit -m "feat(postgres): PgActivityPubRepository implementing ActivityPubRepository port" -``` - ---- - -### Task 3: activitypub adapter — use the port, drop postgres dep - -**Files:** -- Modify: `crates/adapters/activitypub/src/handler.rs` -- Modify: `crates/adapters/activitypub/Cargo.toml` - -- [ ] **Rewrite `crates/adapters/activitypub/src/handler.rs`:** - -```rust -use std::sync::Arc; -use anyhow::{anyhow, Result}; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use url::Url; - -use activitypub_base::ApObjectHandler; -use domain::ports::ActivityPubRepository; -use domain::value_objects::UserId; -use crate::note::ThoughtNote; -use crate::urls::ThoughtsUrls; - -pub struct ThoughtsObjectHandler { - repo: Arc, - urls: ThoughtsUrls, -} - -impl ThoughtsObjectHandler { - pub fn new(repo: Arc, base_url: &str) -> Self { - Self { repo, urls: ThoughtsUrls::new(base_url) } - } -} - -#[async_trait] -impl ApObjectHandler for ThoughtsObjectHandler { - async fn get_local_objects_for_user( - &self, - user_id: uuid::Uuid, - ) -> Result> { - let uid = UserId::from_uuid(user_id); - let entries = self.repo.outbox_entries_for_actor(&uid).await - .map_err(|e| anyhow!("{e}"))?; - entries.into_iter().map(|e| { - let note_url = self.urls.thought_url(e.thought.id.as_uuid()); - let actor_url = self.urls.user_url(e.author_username.as_str()); - let followers = self.urls.user_followers(e.author_username.as_str()); - let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid())); - let note = ThoughtNote::new_public( - note_url.clone(), actor_url, - e.thought.content.as_str().to_owned(), - e.thought.created_at, in_reply_to, - e.thought.sensitive, e.thought.content_warning, followers, - ); - Ok((note_url, serde_json::to_value(¬e)?)) - }).collect() - } - - async fn get_local_objects_page( - &self, - user_id: uuid::Uuid, - before: Option>, - limit: usize, - ) -> Result)>> { - let uid = UserId::from_uuid(user_id); - let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await - .map_err(|e| anyhow!("{e}"))?; - entries.into_iter().map(|e| { - let created_at = e.thought.created_at; - let note_url = self.urls.thought_url(e.thought.id.as_uuid()); - let actor_url = self.urls.user_url(e.author_username.as_str()); - let followers = self.urls.user_followers(e.author_username.as_str()); - let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid())); - let note = ThoughtNote::new_public( - note_url.clone(), actor_url, - e.thought.content.as_str().to_owned(), - created_at, in_reply_to, - e.thought.sensitive, e.thought.content_warning, followers, - ); - Ok((note_url, serde_json::to_value(¬e)?, created_at)) - }).collect() - } - - async fn on_create( - &self, - ap_id: &Url, - actor_url: &Url, - object: serde_json::Value, - ) -> Result<()> { - let note: ThoughtNote = serde_json::from_value(object)?; - let author_id = self.repo.intern_remote_actor(actor_url).await - .map_err(|e| anyhow!("{e}"))?; - self.repo.accept_note( - ap_id, &author_id, - ¬e.content, - note.published, - note.sensitive, - note.summary, - ).await.map_err(|e| anyhow!("{e}")) - } - - async fn on_update( - &self, - ap_id: &Url, - _actor_url: &Url, - object: serde_json::Value, - ) -> Result<()> { - let note: ThoughtNote = serde_json::from_value(object)?; - self.repo.apply_note_update(ap_id, ¬e.content).await - .map_err(|e| anyhow!("{e}")) - } - - async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> { - self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}")) - } - - async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> { - self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}")) - } - - async fn count_local_posts(&self) -> Result { - self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}")) - } -} -``` - -- [ ] **Rewrite `crates/adapters/activitypub/Cargo.toml`** — remove `postgres` and `sqlx`: - -```toml -[package] -name = "activitypub" -version = "0.1.0" -edition = "2021" - -[dependencies] -activitypub-base = { workspace = true } -activitypub_federation = "0.7.0-beta.11" -domain = { workspace = true } -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 } -``` - -- [ ] **Run:** `cargo check -p activitypub` - Expected: no errors. If there are unused import warnings for `sqlx` or `PgPool` — those are now gone, so the check should be clean. - -- [ ] **Run full test suite:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace` - Expected: all 67 tests pass (handler.rs has no unit tests of its own, but the workspace test suite must stay green). - -- [ ] **Commit:** -```bash -git add crates/adapters/activitypub/ -git commit -m "refactor(activitypub): ThoughtsObjectHandler uses ActivityPubRepository port, drops postgres dep" -``` - ---- - -### Task 4: Presentation — wire PgActivityPubRepository - -**Files:** -- Modify: `crates/presentation/src/lib.rs` - -The current `build_state` in `src/lib.rs` calls `ThoughtsObjectHandler::new(pool.clone(), &base_url)`. After Task 3, the signature changed to `ThoughtsObjectHandler::new(repo: Arc, base_url: &str)`. - -- [ ] **Update the import and wiring in `crates/presentation/src/lib.rs`:** - -Find the existing import line: -```rust -use activitypub::ThoughtsObjectHandler; -``` - -Add alongside it: -```rust -use postgres::activitypub::PgActivityPubRepository; -``` - -Find the existing call: -```rust -std::sync::Arc::new(ThoughtsObjectHandler::new(pool.clone(), &base_url)), -``` - -Replace with: -```rust -std::sync::Arc::new(ThoughtsObjectHandler::new( - std::sync::Arc::new(PgActivityPubRepository::new(pool.clone())), - &base_url, -)), -``` - -- [ ] **Run:** `cargo build -p presentation` - Expected: clean build, no errors. - -- [ ] **Run full test suite:** -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 -``` - Expected: all tests pass. - -- [ ] **Verify dependency is gone:** -```bash -cargo tree -p activitypub | grep postgres -``` - Expected: no output — `activitypub` no longer depends on `postgres`. - -- [ ] **Commit:** -```bash -git add crates/presentation/src/lib.rs -git commit -m "fix: wire PgActivityPubRepository into ThoughtsObjectHandler — closes activitypub→postgres violation" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `OutboxEntry` struct with `thought: Thought` + `author_username: Username` -- ✅ `ActivityPubRepository` trait in `domain/src/ports.rs` — 9 methods with federation vocabulary -- ✅ `TestStore impl ActivityPubRepository` — idempotent `intern_remote_actor`, empty stubs for others -- ✅ 2 domain unit tests covering idempotency and empty outbox -- ✅ `PgActivityPubRepository` in `postgres/src/activitypub.rs` — all 9 methods -- ✅ 3 postgres integration tests -- ✅ `ThoughtsObjectHandler` drops `PgPool`, receives `Arc` -- ✅ `activitypub/Cargo.toml` removes `postgres` and `sqlx` deps -- ✅ Presentation wires `PgActivityPubRepository` → `ThoughtsObjectHandler` -- ✅ `cargo tree` verification confirms violation is resolved - -**Placeholder scan:** None. - -**Type consistency:** -- `OutboxEntry` defined in `domain/src/ports.rs`, imported as `domain::ports::OutboxEntry` in postgres — consistent -- `ThoughtsObjectHandler::new(repo: Arc, base_url: &str)` — matches presentation wiring -- `PgActivityPubRepository::new(pool: PgPool)` — matches presentation wiring -- All 9 method signatures identical between trait definition (Task 1) and impl (Task 2) and handler calls (Task 3) diff --git a/docs/superpowers/plans/2026-05-14-api-cleanup.md b/docs/superpowers/plans/2026-05-14-api-cleanup.md deleted file mode 100644 index 6fba046..0000000 --- a/docs/superpowers/plans/2026-05-14-api-cleanup.md +++ /dev/null @@ -1,1054 +0,0 @@ -# REST API Cleanup Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rename routes, unify local/remote follow, add content negotiation at `GET /users/{username}`, and switch notification state changes to PATCH — no new features, pure cleanup. - -**Architecture:** The domain `FederationActionPort` gains `actor_json` so the presentation layer can serve AP actor JSON without depending on `activitypub-base`. Content negotiation happens in a single handler that inspects the `Accept` header. The unified follow handler detects `@` in the path param to route local vs remote. All route string changes land in `routes.rs` and `main.rs`. - -**Tech Stack:** Rust (axum, domain ports), Next.js 15 (App Router), TypeScript, Zod. - ---- - -## File Map - -| Action | Path | Change | -|--------|------|--------| -| Modify | `crates/domain/src/ports.rs` | Add `actor_json` to `FederationActionPort` | -| Modify | `crates/domain/src/testing.rs` | Add `actor_json` to `TestStore` impl + test | -| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `actor_json`; fix handle format in `lookup_actor` | -| Modify | `crates/api-types/src/requests.rs` | Add `NotificationUpdateRequest`; remove `FollowRemoteRequest` | -| Modify | `crates/presentation/src/handlers/notifications.rs` | Replace POST handlers with PATCH | -| Modify | `crates/presentation/src/handlers/users.rs` | Content negotiation in `get_user`; move `lookup_handler` from federation; rename `get_me_following_list` | -| Modify | `crates/presentation/src/handlers/social.rs` | Unified `post_follow`; `delete_follow` rejects remote; fix OpenAPI `{id}`→`{username}` | -| Delete | `crates/presentation/src/handlers/federation.rs` | Both handlers gone: `lookup_handler` → `users.rs`; `follow_remote_handler` → deleted | -| Modify | `crates/presentation/src/handlers/mod.rs` | Remove `pub mod federation;` | -| Modify | `crates/presentation/src/routes.rs` | All route string changes | -| Modify | `crates/bootstrap/src/main.rs` | Remove `/users/{username}` from AP router | -| Modify | `thoughts-frontend/lib/api.ts` | URL/method updates + new notification functions | -| Modify | `thoughts-frontend/components/remote-user-card.tsx` | `followRemoteUser` → `followUser` | - ---- - -## Task 1: Domain — add `actor_json` to `FederationActionPort` - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/testing.rs` - -- [ ] **Step 1: Add `actor_json` to the trait** - -Read `crates/domain/src/ports.rs`. In the `FederationActionPort` trait block, add the new method: - -```rust -#[async_trait] -pub trait FederationActionPort: Send + Sync { - async fn lookup_actor(&self, handle: &str) -> Result; - async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; - async fn actor_json(&self, user_id: &UserId) -> Result; -} -``` - -- [ ] **Step 2: Write the failing test** - -At the bottom of the `federation_port_tests` module in `crates/domain/src/testing.rs`, add: - -```rust -#[tokio::test] -async fn test_store_actor_json_returns_not_found() { - let store = TestStore::default(); - let err = store.actor_json(&UserId::new()).await.unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); -} -``` - -- [ ] **Step 3: Run to see it fail** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 -``` - -Expected: compile error — `actor_json` not in `TestStore`'s `FederationActionPort` impl. - -- [ ] **Step 4: Implement `actor_json` on `TestStore`** - -In `crates/domain/src/testing.rs`, inside `impl FederationActionPort for TestStore`, add: - -```rust -async fn actor_json(&self, _user_id: &UserId) -> Result { - Err(DomainError::NotFound) -} -``` - -- [ ] **Step 5: Run tests to confirm pass** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 -``` - -Expected: all 3 tests pass. - -- [ ] **Step 6: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5 -``` - -- [ ] **Step 7: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/domain/src/ports.rs crates/domain/src/testing.rs -git commit -m "feat(domain): add actor_json to FederationActionPort" -``` - ---- - -## Task 2: activitypub-base — implement `actor_json` + fix handle format - -**Files:** -- Modify: `crates/adapters/activitypub-base/src/service.rs` - -- [ ] **Step 1: Add compile-time assert** - -In `crates/adapters/activitypub-base/src/tests/service.rs`, the existing `_assert_impl_federation_action_port` function will now fail to compile because `actor_json` is missing. Run to confirm: - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10 -``` - -Expected: error about missing `actor_json` impl. - -- [ ] **Step 2: Implement `actor_json` in the `FederationActionPort` impl** - -Read `crates/adapters/activitypub-base/src/service.rs`. In the `impl domain::ports::FederationActionPort for ActivityPubService` block, add after `follow_remote`: - -```rust -async fn actor_json( - &self, - user_id: &domain::value_objects::UserId, -) -> Result { - ActivityPubService::actor_json(self, &user_id.as_uuid().to_string()) - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) -} -``` - -Note: `ActivityPubService::actor_json` is the existing inherent method at line ~210 that takes `&str`. Calling it as `ActivityPubService::actor_json(self, ...)` avoids ambiguity with the trait method. - -- [ ] **Step 3: Fix `lookup_actor` to return full `user@domain` handle** - -In the same file, find the `lookup_actor` impl. Currently it sets `handle: actor.username.clone()` (just the `preferred_username`). Replace the `Ok(...)` block with: - -```rust -let domain_str = actor.ap_id.host_str().unwrap_or(""); -let full_handle = format!("{}@{}", actor.username, domain_str); - -Ok(domain::models::remote_actor::RemoteActor { - url: actor.ap_id.to_string(), - handle: full_handle, - display_name: Some(actor.username.clone()), - inbox_url: actor.inbox_url.to_string(), - shared_inbox_url: None, - public_key: actor.public_key_pem.clone(), - avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), - last_fetched_at: actor.last_refreshed_at, -}) -``` - -- [ ] **Step 4: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 -``` - -Expected: no errors. - -- [ ] **Step 5: Full workspace check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5 -``` - -- [ ] **Step 6: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/adapters/activitypub-base/src/service.rs -git commit -m "feat(activitypub-base): impl actor_json port; return full user@domain handle from lookup" -``` - ---- - -## Task 3: Notification handlers — PATCH - -**Files:** -- Modify: `crates/api-types/src/requests.rs` -- Modify: `crates/presentation/src/handlers/notifications.rs` - -- [ ] **Step 1: Add `NotificationUpdateRequest` and remove `FollowRemoteRequest`** - -Read `crates/api-types/src/requests.rs`. Remove the `FollowRemoteRequest` struct (it was only used by the federation handler being deleted). Add: - -```rust -#[derive(serde::Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct NotificationUpdateRequest { - pub read: bool, -} -``` - -- [ ] **Step 2: Write failing tests** - -Add to `crates/presentation/src/handlers/notifications.rs` (inside a `#[cfg(test)] mod tests` block at the bottom, following the same pattern as `federation.rs` tests — use `TestStore` and `tower::ServiceExt::oneshot`): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::Body, - http::{Request, header}, - routing::{get, patch}, - Router, - }; - use domain::testing::TestStore; - use std::sync::Arc; - use tower::ServiceExt; - - // Re-use the same NoOpAuth/NoOpHasher stubs from federation.rs tests pattern: - // Check crates/presentation/src/handlers/federation.rs for the exact stub code - // and copy it here (NoOpAuth implementing AuthService, NoOpHasher implementing PasswordHasher). - - fn make_state() -> crate::state::AppState { - let store = Arc::new(TestStore::default()); - crate::state::AppState { - users: store.clone(), - thoughts: store.clone(), - likes: store.clone(), - boosts: store.clone(), - follows: store.clone(), - blocks: store.clone(), - tags: store.clone(), - api_keys: store.clone(), - top_friends: store.clone(), - notifications: store.clone(), - remote_actors: store.clone(), - feed: store.clone(), - search: store.clone(), - auth: Arc::new(NoOpAuth), - hasher: Arc::new(NoOpHasher), - events: store.clone(), - federation: store.clone(), - } - } - - fn app() -> Router { - Router::new() - .route("/notifications", patch(mark_all_read)) - .route("/notifications/:id", patch(mark_notification_read)) - .with_state(make_state()) - } - - #[tokio::test] - async fn patch_notification_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/notifications/00000000-0000-0000-0000-000000000001") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"read":true}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } - - #[tokio::test] - async fn patch_all_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/notifications") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"read":true}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } -} -``` - -Note: copy the `NoOpAuth` and `NoOpHasher` struct definitions from `crates/presentation/src/handlers/federation.rs` — they are defined inline in the test module there. - -- [ ] **Step 3: Run to see compile/test failure** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -20 -``` - -Expected: compile error — `mark_notification_read` and `mark_all_read` don't accept JSON body yet. - -- [ ] **Step 4: Replace the POST handlers with PATCH handlers** - -Replace the full content of `crates/presentation/src/handlers/notifications.rs` with: - -```rust -use api_types::requests::NotificationUpdateRequest; -use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; -use application::use_cases::notifications::{ - list_notifications as uc_list_notifications, mark_all_notifications_read, - mark_notification_read as uc_mark_notification_read, -}; -use axum::{ - extract::{Path, State}, - http::StatusCode, - Json, -}; -use domain::{models::feed::PageParams, value_objects::NotificationId}; -use uuid::Uuid; - -pub async fn list_notifications( - State(s): State, - AuthUser(uid): AuthUser, -) -> Result, ApiError> { - let page = PageParams { page: 1, per_page: 20 }; - let result = uc_list_notifications(&*s.notifications, &uid, page).await?; - Ok(Json(serde_json::json!({ - "total": result.total, - "unread": result.items.iter().filter(|n| !n.read).count() - }))) -} - -pub async fn mark_notification_read( - State(s): State, - AuthUser(uid): AuthUser, - Path(id): Path, - Json(body): Json, -) -> Result { - if body.read { - uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?; - } - Ok(StatusCode::NO_CONTENT) -} - -pub async fn mark_all_read( - State(s): State, - AuthUser(uid): AuthUser, - Json(body): Json, -) -> Result { - if body.read { - mark_all_notifications_read(&*s.notifications, &uid).await?; - } - Ok(StatusCode::NO_CONTENT) -} - -#[cfg(test)] -mod tests { - // ... (same test block from Step 2) -} -``` - -- [ ] **Step 5: Run tests to confirm pass** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -10 -``` - -Expected: both tests pass (401 without auth). - -- [ ] **Step 6: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -10 -``` - -If there are errors about `FollowRemoteRequest` still being used (e.g. in `federation.rs`), that's fine — Task 5 deletes that file. - -- [ ] **Step 7: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/api-types/src/requests.rs crates/presentation/src/handlers/notifications.rs -git commit -m "refactor(api): notification state changes use PATCH" -``` - ---- - -## Task 4: Users handler — content negotiation + lookup move - -**Files:** -- Modify: `crates/presentation/src/handlers/users.rs` - -- [ ] **Step 1: Write failing tests** - -Add a `#[cfg(test)] mod tests` block at the bottom of `crates/presentation/src/handlers/users.rs`. The NoOpAuth/NoOpHasher pattern is the same as in Task 3. Add: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::Body, - http::{Request, header}, - routing::get, - Router, - }; - use domain::testing::TestStore; - use std::sync::Arc; - use tower::ServiceExt; - - // (copy NoOpAuth, NoOpHasher structs from federation.rs test module) - - fn make_state() -> crate::state::AppState { - let store = Arc::new(TestStore::default()); - crate::state::AppState { - users: store.clone(), - thoughts: store.clone(), - likes: store.clone(), - boosts: store.clone(), - follows: store.clone(), - blocks: store.clone(), - tags: store.clone(), - api_keys: store.clone(), - top_friends: store.clone(), - notifications: store.clone(), - remote_actors: store.clone(), - feed: store.clone(), - search: store.clone(), - auth: Arc::new(NoOpAuth), - hasher: Arc::new(NoOpHasher), - events: store.clone(), - federation: store.clone(), - } - } - - fn app() -> Router { - Router::new() - .route("/users/:username", get(get_user)) - .route("/users/lookup", get(lookup_handler)) - .with_state(make_state()) - } - - #[tokio::test] - async fn get_unknown_user_returns_404() { - let resp = app() - .oneshot(Request::builder().uri("/users/nobody").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } - - #[tokio::test] - async fn get_user_with_ap_accept_calls_actor_json_returns_404_when_not_found() { - // TestStore.actor_json returns NotFound, so AP requests to unknown users → 404 - let resp = app() - .oneshot( - Request::builder() - .uri("/users/nobody") - .header(header::ACCEPT, "application/activity+json") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } - - #[tokio::test] - async fn lookup_unknown_handle_returns_404() { - let resp = app() - .oneshot( - Request::builder() - .uri("/users/lookup?handle=%40alice%40example.com") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } -} -``` - -- [ ] **Step 2: Run to confirm tests compile but need implementation changes** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -20 -``` - -Expected: compile errors until we add `lookup_handler` to users.rs and modify `get_user`. - -- [ ] **Step 3: Update `users.rs`** - -Read the full `crates/presentation/src/handlers/users.rs`. - -**3a. Add new imports at the top:** - -```rust -use axum::http::{HeaderMap, header}; -use axum::response::{IntoResponse, Response}; -use api_types::responses::RemoteActorResponse; -``` - -**3b. Replace the `get_user` handler** (currently returns `Result, ApiError>`) with: - -```rust -pub async fn get_user( - State(s): State, - Path(username): Path, - OptionalAuthUser(viewer): OptionalAuthUser, - headers: HeaderMap, -) -> Result { - let user = get_user_by_username(&*s.users, &username).await?; - - let accept = headers - .get(header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - if accept.contains("application/activity+json") { - let json = s.federation.actor_json(&user.id).await?; - Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()) - } else { - let is_followed = if let Some(viewer_id) = viewer { - s.follows.find(&viewer_id, &user.id).await?.is_some() - } else { - false - }; - let mut resp = to_user_response(&user); - resp.is_followed_by_viewer = is_followed; - Ok(Json(resp).into_response()) - } -} -``` - -**3c. Rename `get_me_following_list` → `get_me_following`** (just the function name — update it in place): - -Find `pub async fn get_me_following_list` and rename to `pub async fn get_me_following`. - -**3d. Add `LookupQuery` and `lookup_handler` from `federation.rs`:** - -```rust -#[derive(serde::Deserialize)] -pub struct LookupQuery { - pub handle: String, -} - -pub async fn lookup_handler( - State(s): State, - Query(q): Query, -) -> Result, ApiError> { - let actor = s.federation.lookup_actor(&q.handle).await?; - Ok(Json(RemoteActorResponse { - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - url: actor.url, - })) -} -``` - -- [ ] **Step 4: Run tests to confirm pass** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -10 -``` - -Expected: all 3 tests pass. - -- [ ] **Step 5: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10 -``` - -There will be errors about `federation.rs` still defining `lookup_handler` (duplicate) — that's resolved in Task 5 when we delete `federation.rs`. For now, just ensure `users.rs` itself compiles. - -- [ ] **Step 6: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/presentation/src/handlers/users.rs -git commit -m "refactor(users): content negotiation at GET /users/{username}; move lookup handler" -``` - ---- - -## Task 5: Social handler cleanup + delete `federation.rs` - -**Files:** -- Modify: `crates/presentation/src/handlers/social.rs` -- Delete: `crates/presentation/src/handlers/federation.rs` -- Modify: `crates/presentation/src/handlers/mod.rs` - -- [ ] **Step 1: Write failing tests for unified follow** - -Add a `#[cfg(test)] mod tests` block at the bottom of `crates/presentation/src/handlers/social.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::Body, - http::Request, - routing::{delete, post}, - Router, - }; - use domain::testing::TestStore; - use std::sync::Arc; - use tower::ServiceExt; - - // (copy NoOpAuth, NoOpHasher structs from federation.rs test module) - - fn make_state() -> crate::state::AppState { - let store = Arc::new(TestStore::default()); - crate::state::AppState { - users: store.clone(), - thoughts: store.clone(), - likes: store.clone(), - boosts: store.clone(), - follows: store.clone(), - blocks: store.clone(), - tags: store.clone(), - api_keys: store.clone(), - top_friends: store.clone(), - notifications: store.clone(), - remote_actors: store.clone(), - feed: store.clone(), - search: store.clone(), - auth: Arc::new(NoOpAuth), - hasher: Arc::new(NoOpHasher), - events: store.clone(), - federation: store.clone(), - } - } - - fn app() -> Router { - Router::new() - .route("/users/:username/follow", post(post_follow).delete(delete_follow)) - .with_state(make_state()) - } - - #[tokio::test] - async fn follow_without_auth_returns_401() { - let resp = app() - .oneshot(Request::builder().method("POST").uri("/users/alice/follow").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } - - #[tokio::test] - async fn unfollow_remote_handle_without_auth_returns_401() { - let resp = app() - .oneshot(Request::builder().method("DELETE").uri("/users/alice@example.com/follow").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } -} -``` - -- [ ] **Step 2: Run to see compile state** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -15 -``` - -- [ ] **Step 3: Update `post_follow` to unify local and remote follows** - -In `crates/presentation/src/handlers/social.rs`, replace `post_follow` with: - -```rust -#[utoipa::path( - post, path = "/users/{username}/follow", - params(("username" = String, Path, description = "Username or user@domain handle")), - responses((status = 204, description = "Following")), - security(("bearer_auth" = [])) -)] -pub async fn post_follow( - State(s): State, - AuthUser(uid): AuthUser, - Path(username): Path, -) -> Result { - if username.contains('@') { - s.federation.follow_remote(&uid, &username).await?; - } else { - let target = get_user_by_username(&*s.users, &username).await?; - follow_user(&*s.follows, &*s.events, &uid, &target.id).await?; - } - Ok(StatusCode::NO_CONTENT) -} -``` - -- [ ] **Step 4: Update `delete_follow` to reject remote handles** - -Replace `delete_follow` with: - -```rust -#[utoipa::path( - delete, path = "/users/{username}/follow", - params(("username" = String, Path, description = "Username")), - responses((status = 204, description = "Unfollowed")), - security(("bearer_auth" = [])) -)] -pub async fn delete_follow( - State(s): State, - AuthUser(uid): AuthUser, - Path(username): Path, -) -> Result { - if username.contains('@') { - return Err(ApiError::BadRequest("remote unfollow not yet supported".into())); - } - let target = get_user_by_username(&*s.users, &username).await?; - unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?; - Ok(StatusCode::NO_CONTENT) -} -``` - -- [ ] **Step 5: Fix `{id}` → `{username}` in OpenAPI annotations for block handlers** - -In `social.rs`, update the `#[utoipa::path]` annotations on `post_block` and `delete_block`: - -- Change `path = "/users/{id}/block"` → `path = "/users/{username}/block"` -- Change `("id" = uuid::Uuid, Path, description = "User ID")` → `("username" = String, Path, description = "Username")` - -Same for `post_follow` and `delete_follow` (already done in steps above). - -- [ ] **Step 6: Delete `federation.rs` and update `mod.rs`** - -Delete the file: -```bash -rm /mnt/drive/dev/thoughts/crates/presentation/src/handlers/federation.rs -``` - -In `crates/presentation/src/handlers/mod.rs`, remove the line: -```rust -pub mod federation; -``` - -- [ ] **Step 7: Run tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -10 -``` - -Expected: both tests pass (401 without auth). - -- [ ] **Step 8: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10 -``` - -Expected: no errors (all `federation::` references removed from routes in next task — routes.rs will fail until Task 6). - -- [ ] **Step 9: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/presentation/src/handlers/social.rs \ - crates/presentation/src/handlers/mod.rs -git rm crates/presentation/src/handlers/federation.rs -git commit -m "refactor(social): unified follow handler; remove federation handler module" -``` - ---- - -## Task 6: Routes + bootstrap - -**Files:** -- Modify: `crates/presentation/src/routes.rs` -- Modify: `crates/bootstrap/src/main.rs` - -- [ ] **Step 1: Replace `routes.rs` with the cleaned-up route table** - -Read `crates/presentation/src/routes.rs` first. Replace the full `api_routes` builder chain with: - -```rust -pub fn router() -> Router { - let api_routes = Router::new() - // health - .route("/health", get(health::health_handler)) - // auth - .route("/auth/register", post(auth::post_register)) - .route("/auth/login", post(auth::post_login)) - // users — static before parameterised - .route("/users", get(users::get_users)) - .route("/users/count", get(users::get_user_count)) - .route("/users/lookup", get(users::lookup_handler)) - .route( - "/users/me", - get(users::get_me).patch(users::patch_profile), - ) - .route("/users/me/following", get(users::get_me_following)) - .route("/users/me/top-friends", put(social::put_top_friends)) - .route("/users/{username}", get(users::get_user)) - .route( - "/users/{username}/top-friends", - get(social::get_top_friends_handler), - ) - .route( - "/users/{username}/follow", - post(social::post_follow).delete(social::delete_follow), - ) - .route( - "/users/{username}/block", - post(social::post_block).delete(social::delete_block), - ) - .route( - "/users/{username}/followers", - get(feed::get_followers_handler), - ) - .route( - "/users/{username}/following", - get(feed::get_following_handler), - ) - .route( - "/users/{username}/thoughts", - get(feed::user_thoughts_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), - ) - // feeds - .route("/feed", get(feed::home_feed)) - .route("/feed/public", get(feed::public_feed)) - .route("/search", get(feed::search_handler)) - .route("/tags/popular", get(feed::get_popular_tags)) - .route("/tags/{name}", get(feed::tag_thoughts_handler)) - // notifications - .route( - "/notifications", - get(notifications::list_notifications).patch(notifications::mark_all_read), - ) - .route( - "/notifications/{id}", - patch(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)); - - openapi::serve(api_routes) -} -``` - -Make sure `patch` is imported: `use axum::routing::{delete, get, patch, post, put};`. - -- [ ] **Step 2: Remove `/users/{username}` from the AP router in `main.rs`** - -Read `crates/bootstrap/src/main.rs`. In the `ap_router` builder, remove this line: - -```rust -.route("/users/{username}", axum::routing::get(actor_handler)) -``` - -Also remove the `actor_handler` import from `activitypub_base` if it's no longer used anywhere in `main.rs`: - -```rust -use activitypub_base::{ - actor_handler::actor_handler, // ← remove this line - followers_handler::{followers_handler, following_handler}, - ... -}; -``` - -- [ ] **Step 3: Full compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -15 -``` - -Expected: no errors. If `actor_handler` is still imported but unused, remove it. - -- [ ] **Step 4: Run all tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -10 -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/presentation/src/routes.rs crates/bootstrap/src/main.rs -git commit -m "refactor(routes): clean RESTful route table; content negotiation at /users/{username}" -``` - ---- - -## Task 7: Frontend — `api.ts` + `remote-user-card.tsx` - -**Files:** -- Modify: `thoughts-frontend/lib/api.ts` -- Modify: `thoughts-frontend/components/remote-user-card.tsx` - -- [ ] **Step 1: Update all changed URLs and methods in `api.ts`** - -Read `thoughts-frontend/lib/api.ts`. Make these targeted edits: - -**`getUserProfile`** — change URL: -```typescript -export const getUserProfile = (username: string, token: string | null) => - apiFetch(`/users/${username}`, {}, UserSchema, token); -``` - -**`getFollowersList`** — change URL: -```typescript -export const getFollowersList = (username: string, token: string | null) => - apiFetch(`/users/${username}/followers`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); -``` - -**`getFollowingList`** — change URL: -```typescript -export const getFollowingList = (username: string, token: string | null) => - apiFetch(`/users/${username}/following`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); -``` - -**`getMeFollowingList`** — change URL: -```typescript -export const getMeFollowingList = (token: string) => - apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); -``` - -**`lookupRemoteActor`** — change URL: -```typescript -export const lookupRemoteActor = (handle: string, token: string | null) => - apiFetch( - `/users/lookup?handle=${encodeURIComponent(handle)}`, - {}, - RemoteActorSchema, - token - ); -``` - -**Delete `followRemoteUser`** — remove this entire function (unified follow now uses `followUser` with the full `user@domain` handle): -```typescript -// DELETE this: -export const followRemoteUser = (handle: string, token: string) => - apiFetch( - `/federation/follow`, - { method: "POST", body: JSON.stringify({ handle }) }, - z.null(), - token - ); -``` - -**Add `markNotificationRead`**: -```typescript -export const markNotificationRead = (id: string, token: string) => - apiFetch( - `/notifications/${id}`, - { method: "PATCH", body: JSON.stringify({ read: true }) }, - z.null(), - token - ); -``` - -**Add `markAllNotificationsRead`**: -```typescript -export const markAllNotificationsRead = (token: string) => - apiFetch( - "/notifications", - { method: "PATCH", body: JSON.stringify({ read: true }) }, - z.null(), - token - ); -``` - -- [ ] **Step 2: Update `remote-user-card.tsx`** - -Read `thoughts-frontend/components/remote-user-card.tsx`. Change the follow button's action from `followRemoteUser` to `followUser`: - -Replace: -```typescript -import { followRemoteUser, RemoteActor } from "@/lib/api"; -``` -With: -```typescript -import { followUser, RemoteActor } from "@/lib/api"; -``` - -Replace: -```typescript -await followRemoteUser(actor.handle, token); -``` -With: -```typescript -await followUser(actor.handle, token); -``` - -This works because `actor.handle` is now the full `user@domain` format (e.g. `gabrielkaszewski@mastodon.social`) from the fixed `lookup_actor`, and `followUser` calls `POST /users/gabrielkaszewski@mastodon.social/follow`, which the unified handler detects as a remote follow. - -- [ ] **Step 3: Type-check** - -```bash -cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 -``` - -Expected: no errors. If any page references `followRemoteUser`, update it to `followUser`. - -- [ ] **Step 4: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add thoughts-frontend/lib/api.ts thoughts-frontend/components/remote-user-card.tsx -git commit -m "refactor(frontend): update API client to match cleaned REST routes" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `GET /users/{username}` content negotiation — Tasks 1, 2, 4, 6 -- ✅ `GET /users/lookup` moved from `/federation/lookup` — Tasks 4, 6 -- ✅ `POST /users/{username}/follow` unified — Task 5, 6 -- ✅ `DELETE /users/{username}/follow` 400 for remote — Task 5 -- ✅ `{id}` → `{username}` param rename in follow/block — Tasks 5, 6 -- ✅ `followers`/`following` route rename — Task 6 -- ✅ `me/following` rename — Tasks 4, 6 -- ✅ `PATCH /notifications/{id}` — Tasks 3, 6 -- ✅ `PATCH /notifications` bulk — Tasks 3, 6 -- ✅ `PUT /users/me` removed — Task 6 -- ✅ `POST /federation/follow` removed — Tasks 5, 6 -- ✅ Frontend api.ts updates — Task 7 -- ✅ `remote-user-card.tsx` followUser — Task 7 -- ✅ Handle format fix (`user@domain`) in `lookup_actor` — Task 2 - -**Placeholder scan:** None found. - -**Type consistency:** -- `actor_json(&self, user_id: &UserId)` defined in Task 1, implemented in Task 2, called in Task 4 ✅ -- `get_me_following` renamed in Task 4, referenced in Task 6 routes ✅ -- `lookup_handler` defined in Task 4 (users.rs), referenced in Task 6 routes as `users::lookup_handler` ✅ -- `NotificationUpdateRequest` defined in Task 3 (api-types), used in Task 3 (notifications.rs) ✅ -- `followUser(actor.handle, token)` — `actor.handle` is full `user@domain` after Task 2 fix ✅ diff --git a/docs/superpowers/plans/2026-05-14-audit-gap-fixes.md b/docs/superpowers/plans/2026-05-14-audit-gap-fixes.md deleted file mode 100644 index 0af4b82..0000000 --- a/docs/superpowers/plans/2026-05-14-audit-gap-fixes.md +++ /dev/null @@ -1,360 +0,0 @@ -# Audit Gap Fixes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close the three gaps found in the architectural audit: `unblock_user` publishing a `UserUnblocked` event, `register` publishing a `UserRegistered` event, and the worker creating Reply notifications when a thought is a reply. - -**Architecture:** Two new `DomainEvent` variants (`UserUnblocked`, `UserRegistered`) ripple through the event pipeline: added to `events.rs`, serialised in `event-payload`, published in the affected use cases. The worker `NotificationHandler` gains a new arm for `ThoughtCreated` with an `in_reply_to_id`. - -**Tech Stack:** Rust, existing domain/event-payload/application/worker crates - ---- - -## File Map - -``` -Modify: crates/domain/src/events.rs ← add UserUnblocked + UserRegistered variants -Modify: crates/adapters/event-payload/src/lib.rs ← add variants + From<&DomainEvent> + TryFrom arms -Modify: crates/application/src/use_cases/social.rs ← unblock_user accepts events, publishes UserUnblocked -Modify: crates/application/src/use_cases/auth.rs ← register publishes UserRegistered -Modify: crates/presentation/src/handlers/social.rs ← delete_block passes &*s.events -Modify: crates/worker/src/handlers.rs ← ThoughtCreated arm → Reply notification -``` - ---- - -### Task 1: New DomainEvent variants + event-payload + use case fixes - -**Files:** -- Modify: `crates/domain/src/events.rs` -- Modify: `crates/adapters/event-payload/src/lib.rs` -- Modify: `crates/application/src/use_cases/social.rs` -- Modify: `crates/application/src/use_cases/auth.rs` -- Modify: `crates/presentation/src/handlers/social.rs` - -- [ ] **Write failing tests** — add to `crates/application/src/use_cases/social.rs` test module (bottom of existing `#[cfg(test)] mod tests`): - -```rust - #[tokio::test] - async fn unblock_user_publishes_event() { - let store = TestStore::default(); - let alice = user("alice"); - let bob = user("bob"); - // block first so we can unblock - block_user(&store, &store, &alice.id, &bob.id).await.unwrap(); - store.events.lock().unwrap().clear(); // reset after block event - unblock_user(&store, &store, &alice.id, &bob.id).await.unwrap(); - let events = store.events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], DomainEvent::UserUnblocked { .. })); - } -``` - -Add to `crates/application/src/use_cases/auth.rs` test module: - -```rust - #[tokio::test] - async fn register_publishes_user_registered_event() { - let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &store, input()).await.unwrap(); - let events = store.events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], DomainEvent::UserRegistered { .. })); - } -``` - -Note: in the auth test, `&store` is passed as the `events` argument (TestStore implements EventPublisher). The existing tests use `&NoOpEventPublisher` — leave those unchanged, they still pass. Only the new test passes `&store` to capture events. - -- [ ] **Run:** `cargo test -p application` — Expected: FAIL (UserUnblocked + UserRegistered not defined). - -- [ ] **Add variants to `crates/domain/src/events.rs`** — append two variants to the `DomainEvent` enum, after `UserBlocked`: - -```rust - UserUnblocked { blocker_id: UserId, blocked_id: UserId }, - UserRegistered { user_id: UserId }, -``` - -- [ ] **Add variants to `crates/adapters/event-payload/src/lib.rs`**: - -**In the `EventPayload` enum** — append after `UserBlocked`: - -```rust - UserUnblocked { - blocker_id: String, - blocked_id: String, - }, - UserRegistered { - user_id: String, - }, -``` - -**In `subject()`** — append after the `Self::UserBlocked` arm: - -```rust - Self::UserUnblocked { .. } => "users.unblocked", - Self::UserRegistered { .. } => "users.registered", -``` - -**In `impl From<&DomainEvent> for EventPayload`** — append after the `DomainEvent::UserBlocked` arm: - -```rust - DomainEvent::UserUnblocked { blocker_id, blocked_id } => Self::UserUnblocked { - blocker_id: blocker_id.to_string(), - blocked_id: blocked_id.to_string(), - }, - DomainEvent::UserRegistered { user_id } => Self::UserRegistered { - user_id: user_id.to_string(), - }, -``` - -**In `impl TryFrom for DomainEvent`** — append after the `EventPayload::UserBlocked` arm: - -```rust - EventPayload::UserUnblocked { blocker_id, blocked_id } => DomainEvent::UserUnblocked { - blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), - blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?), - }, - EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered { - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - }, -``` - -- [ ] **Update `unblock_user` in `crates/application/src/use_cases/social.rs`**: - -Replace the current function (which takes only `blocks` and two UserId params): - -```rust -pub async fn unblock_user(blocks: &dyn BlockRepository, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - blocks.delete(blocker_id, blocked_id).await?; - Ok(()) -} -``` - -With: - -```rust -pub async fn unblock_user( - blocks: &dyn BlockRepository, - events: &dyn EventPublisher, - blocker_id: &UserId, - blocked_id: &UserId, -) -> Result<(), DomainError> { - blocks.delete(blocker_id, blocked_id).await?; - events.publish(&DomainEvent::UserUnblocked { - blocker_id: blocker_id.clone(), - blocked_id: blocked_id.clone(), - }).await?; - Ok(()) -} -``` - -- [ ] **Update `register` in `crates/application/src/use_cases/auth.rs`**: - -Change the parameter from `_events` to `events` (remove the underscore) and add one line after `users.save(&user).await?;`: - -```rust -pub async fn register( - users: &dyn UserRepository, - hasher: &dyn PasswordHasher, - auth: &dyn AuthService, - events: &dyn EventPublisher, // ← remove leading underscore - input: RegisterInput, -) -> Result { - let username = Username::new(input.username)?; - let email = Email::new(input.email)?; - if users.find_by_username(&username).await?.is_some() { - return Err(DomainError::Conflict("username taken".into())); - } - if users.find_by_email(&email).await?.is_some() { - return Err(DomainError::Conflict("email taken".into())); - } - let hash = hasher.hash(&input.password).await?; - let user = User::new_local(UserId::new(), username, email, hash); - users.save(&user).await?; - events.publish(&DomainEvent::UserRegistered { user_id: user.id.clone() }).await?; // ← new - let token = auth.generate_token(&user.id)?; - Ok(RegisterOutput { user, token: token.token }) -} -``` - -- [ ] **Update `delete_block` handler in `crates/presentation/src/handlers/social.rs`**: - -The handler currently calls `unblock_user(&*s.blocks, &uid, &UserId::from_uuid(target))`. Add `&*s.events` as the second argument: - -```rust -pub async fn delete_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { - unblock_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?; - Ok(StatusCode::NO_CONTENT) -} -``` - -- [ ] **Run:** `cargo test -p application` — Expected: all tests pass including 2 new ones. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors (the handler change + event-payload additions must compile). - -- [ ] **Commit:** - -```bash -git add crates/domain/src/events.rs \ - crates/adapters/event-payload/src/lib.rs \ - crates/application/src/use_cases/social.rs \ - crates/application/src/use_cases/auth.rs \ - crates/presentation/src/handlers/social.rs -git commit -m "feat: UserUnblocked + UserRegistered events, fix unblock_user and register signatures" -``` - ---- - -### Task 2: Reply notifications in worker - -**Files:** -- Modify: `crates/worker/src/handlers.rs` - -- [ ] **Write the failing test** — add to the existing `#[cfg(test)] mod tests` block in `crates/worker/src/handlers.rs`, after `follow_accepted_creates_notification`: - -```rust - #[tokio::test] - async fn reply_creates_notification_for_original_author() { - let store = TestStore::default(); - let alice = alice(); // author of the original thought - let bob_id = UserId::new(); // author of the reply - - let original = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("original thought").unwrap(), - None, Visibility::Public, None, false, - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(original.clone()); - - let reply_id = ThoughtId::new(); - let handler = NotificationHandler { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - - // ThoughtCreated with in_reply_to_id pointing at alice's thought - handler.handle(&DomainEvent::ThoughtCreated { - thought_id: reply_id, - user_id: bob_id.clone(), - in_reply_to_id: Some(original.id.clone()), - }).await.unwrap(); - - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert_eq!(notifs[0].user_id, alice.id); - assert!(matches!(notifs[0].notification_type, NotificationType::Reply)); - } - - #[tokio::test] - async fn self_reply_does_not_create_notification() { - let store = TestStore::default(); - let alice = alice(); - let original = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("original").unwrap(), - None, Visibility::Public, None, false, - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(original.clone()); - - let handler = NotificationHandler { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - - handler.handle(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: alice.id.clone(), // alice replying to herself - in_reply_to_id: Some(original.id.clone()), - }).await.unwrap(); - - assert!(store.notifications.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn thought_without_reply_to_creates_no_notification() { - let store = TestStore::default(); - let alice = alice(); - store.users.lock().unwrap().push(alice.clone()); - - let handler = NotificationHandler { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - - handler.handle(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: alice.id.clone(), - in_reply_to_id: None, // not a reply - }).await.unwrap(); - - assert!(store.notifications.lock().unwrap().is_empty()); - } -``` - -- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p worker` — Expected: FAIL on 3 new tests (reply handling not implemented). - -- [ ] **Add the `ThoughtCreated` arm** to `NotificationHandler::handle` in `crates/worker/src/handlers.rs` — insert before the final `_ => Ok(()),` arm: - -```rust - DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => { - let reply_to_id = match in_reply_to_id { - Some(id) => id, - None => return Ok(()), // not a reply — no notification needed - }; - let original = match self.thoughts.find_by_id(reply_to_id).await? { - Some(t) => t, - None => return Ok(()), // original thought deleted — skip - }; - if original.user_id == *user_id { return Ok(()); } // no self-notifications - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: original.user_id, - notification_type: NotificationType::Reply, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await - } -``` - -- [ ] **Run:** `cargo test -p worker` — Expected: all 6 tests pass (3 existing + 3 new). - -- [ ] **Run full suite:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3` — Expected: all tests pass. - -- [ ] **Commit:** - -```bash -git add crates/worker/src/handlers.rs -git commit -m "feat(worker): Reply notification when ThoughtCreated has in_reply_to_id" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `UserUnblocked` added to DomainEvent (Task 1) -- ✅ `UserRegistered` added to DomainEvent (Task 1) -- ✅ Both variants added to EventPayload with subject routing (Task 1) -- ✅ Both variants covered in From<&DomainEvent> and TryFrom (Task 1) -- ✅ `unblock_user` now accepts `events` and publishes `UserUnblocked` (Task 1) -- ✅ `register` now publishes `UserRegistered` (Task 1) -- ✅ `delete_block` handler passes `&*s.events` (Task 1) -- ✅ `ThoughtCreated` with `in_reply_to_id` triggers Reply notification (Task 2) -- ✅ Self-reply suppressed (Task 2) -- ✅ Plain thought (no reply) triggers no notification (Task 2) - -**Placeholder scan:** None. - -**Type consistency:** -- `DomainEvent::UserUnblocked { blocker_id: UserId, blocked_id: UserId }` — matches use case publish call and EventPayload From arm -- `DomainEvent::UserRegistered { user_id: UserId }` — matches use case publish call and EventPayload From arm -- `NotificationType::Reply` — already exists in `domain/src/models/notification.rs` -- `unblock_user(blocks, events, blocker_id, blocked_id)` — matches updated handler call in `delete_block` - -**Notes:** -- `NotificationType::Reply` was already defined in domain models (Plan 1) — no domain model change needed -- The `event-payload` `all_subjects_are_unique` test will catch duplicate NATS subjects — the new subjects "users.unblocked" and "users.registered" are unique diff --git a/docs/superpowers/plans/2026-05-14-bootstrap-factory.md b/docs/superpowers/plans/2026-05-14-bootstrap-factory.md deleted file mode 100644 index f5e7132..0000000 --- a/docs/superpowers/plans/2026-05-14-bootstrap-factory.md +++ /dev/null @@ -1,431 +0,0 @@ -# Bootstrap Factory Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract the composition root out of `presentation` into a dedicated `bootstrap` crate with a `factory.rs` that builds all dependencies from config — so `presentation` becomes a pure HTTP library with no knowledge of concrete adapters. - -**Architecture:** `crates/bootstrap/` is a new binary crate. It contains `config.rs` (reads env vars), `factory.rs` (creates all concrete `Arc` adapters and returns `Infrastructure { state, fed_config }`), and a thin `main.rs`. `presentation` loses `[[bin]]`, `build_state`, and all concrete adapter imports — it only depends on `domain`, `application`, `api-types`, `axum`, `activitypub-base`, and UI/docs libs. - -**Tech Stack:** existing Rust workspace, sqlx, async-nats, all existing adapter crates - ---- - -## File Map - -``` -Create: crates/bootstrap/Cargo.toml ← binary crate, imports all concrete adapters -Create: crates/bootstrap/src/config.rs ← Config struct + from_env() -Create: crates/bootstrap/src/factory.rs ← build(config) → Infrastructure { state, fed_config } -Create: crates/bootstrap/src/main.rs ← thin: read config, call factory, serve - -Modify: Cargo.toml (root) ← add "crates/bootstrap" to workspace members -Modify: crates/presentation/Cargo.toml ← remove [[bin]], remove all concrete adapter deps -Modify: crates/presentation/src/lib.rs ← remove build_state + NoOpEventPublisher + imports -Modify: crates/presentation/src/state.rs ← remove fed_config field -Delete: crates/presentation/src/main.rs ← binary moves to bootstrap -``` - -**Key design decision:** `fed_config` is removed from `AppState`. `factory::build()` returns `Infrastructure { state, fed_config }` separately. `main.rs` passes them independently to `router(&infra.fed_config).with_state(infra.state)`. This makes `AppState` pure `Arc` with no infrastructure types. - ---- - -### Task 1: Create bootstrap crate - -**Files:** -- Create: `crates/bootstrap/Cargo.toml` -- Create: `crates/bootstrap/src/config.rs` -- Create: `crates/bootstrap/src/factory.rs` -- Create: `crates/bootstrap/src/main.rs` -- Modify: `Cargo.toml` (root) - -- [ ] **Add `"crates/bootstrap"` to `[workspace] members`** in root `Cargo.toml`. - -- [ ] **Create `crates/bootstrap/Cargo.toml`:** - -```toml -[package] -name = "bootstrap" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "thoughts" -path = "src/main.rs" - -[dependencies] -presentation = { workspace = true } -domain = { workspace = true } -postgres = { workspace = true } -postgres-search = { workspace = true } -postgres-federation = { workspace = true } -activitypub = { workspace = true } -activitypub-base = { workspace = true } -nats = { workspace = true } -auth = { workspace = true } -sqlx = { workspace = true } -async-nats = { workspace = true } -async-trait = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tower-http = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -dotenvy = { workspace = true } -``` - -- [ ] **Create `crates/bootstrap/src/config.rs`:** - -```rust -/// All configuration read from environment variables at startup. -pub struct Config { - pub database_url: String, - pub jwt_secret: String, - pub base_url: String, - pub nats_url: Option, - pub port: u16, - pub allow_registration: bool, - /// true when RUST_ENV != "production" — enables AP debug mode - pub debug: bool, -} - -impl Config { - pub fn from_env() -> Self { - dotenvy::dotenv().ok(); - Self { - database_url: std::env::var("DATABASE_URL") - .expect("DATABASE_URL is required"), - jwt_secret: std::env::var("JWT_SECRET") - .expect("JWT_SECRET is required"), - base_url: std::env::var("BASE_URL") - .unwrap_or_else(|_| "http://localhost:3000".into()), - nats_url: std::env::var("NATS_URL").ok(), - port: std::env::var("PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(3000), - allow_registration: std::env::var("ALLOW_REGISTRATION") - .map(|v| v == "true") - .unwrap_or(true), - debug: std::env::var("RUST_ENV") - .map(|v| v != "production") - .unwrap_or(true), - } - } -} -``` - -- [ ] **Create `crates/bootstrap/src/factory.rs`:** - -```rust -use std::sync::Arc; -use async_trait::async_trait; -use sqlx::PgPool; - -use activitypub::ThoughtsObjectHandler; -use activitypub_base::{ApFederationConfig, FederationData}; -use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; -use postgres::activitypub::PgActivityPubRepository; -use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; -use presentation::state::AppState; - -use crate::config::Config; - -/// Everything the binary needs to start serving: the axum state and the -/// federation config (used when building the router). -pub struct Infrastructure { - pub state: AppState, - pub fed_config: ApFederationConfig, -} - -// ── No-op publisher (fallback when NATS is unavailable) ────────────────────── - -struct NoOpEventPublisher; - -#[async_trait] -impl EventPublisher for NoOpEventPublisher { - async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } -} - -// ── Factory ─────────────────────────────────────────────────────────────────── - -pub async fn build(cfg: &Config) -> Infrastructure { - // 1. Database connection + migrations - let pool = PgPool::connect(&cfg.database_url) - .await - .expect("Failed to connect to database"); - sqlx::migrate!("../adapters/postgres/migrations") - .run(&pool) - .await - .expect("Failed to run migrations"); - tracing::info!("Database connected and migrations applied"); - - // 2. Event publisher — real NATS or no-op fallback - let event_publisher: Arc = match &cfg.nats_url { - Some(url) => match async_nats::connect(url).await { - Ok(client) => { - tracing::info!("Connected to NATS at {url}"); - Arc::new(nats::NatsEventPublisher::new(client)) - } - Err(e) => { - tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher"); - Arc::new(NoOpEventPublisher) - } - }, - None => { - tracing::info!("NATS_URL not set — using no-op event publisher"); - Arc::new(NoOpEventPublisher) - } - }; - - // 3. ActivityPub federation - let fed_data = FederationData::new( - Arc::new(PostgresFederationRepository::new(pool.clone())), - Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), - Arc::new(ThoughtsObjectHandler::new( - Arc::new(PgActivityPubRepository::new(pool.clone())), - &cfg.base_url, - )), - cfg.base_url.clone(), - cfg.allow_registration, - "thoughts".to_string(), - None, // event_publisher wired separately via NATS - ); - let fed_config = ApFederationConfig::new(fed_data, cfg.debug) - .await - .expect("Failed to build federation config"); - - // 4. Application state — all concrete repos injected as Arc - let state = AppState { - users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), - thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), - likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), - boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), - follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), - blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), - tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), - api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), - top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), - notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), - remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), - feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), - search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())), - auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)), - hasher: Arc::new(auth::Argon2PasswordHasher), - events: event_publisher, - }; - - Infrastructure { state, fed_config } -} -``` - -- [ ] **Create `crates/bootstrap/src/main.rs`:** - -```rust -mod config; -mod factory; - -use tower_http::cors::CorsLayer; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() { - let cfg = config::Config::from_env(); - - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - - let infra = factory::build(&cfg).await; - - let app = presentation::routes::router(&infra.fed_config) - .with_state(infra.state) - .layer(CorsLayer::permissive()); - - let addr = format!("0.0.0.0:{}", cfg.port); - tracing::info!("Listening on {addr}"); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} -``` - -- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors. - Note: `presentation` still has its old `[[bin]]` at this point — that's fine, both binaries exist temporarily. - -- [ ] **Smoke test from bootstrap:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ -JWT_SECRET=dev BASE_URL=http://localhost:3000 \ -RUST_LOG=info cargo run --bin thoughts -``` - -Open a second terminal: -```bash -curl -s -X POST http://localhost:3000/auth/register \ - -H 'content-type: application/json' \ - -d '{"username":"bootstraptest","email":"boot@test.com","password":"pw"}' | jq .token -``` -Expected: returns a JWT token. - -- [ ] **Commit:** -```bash -git add Cargo.toml crates/bootstrap/ -git commit -m "feat(bootstrap): composition root with Config + factory.rs" -``` - ---- - -### Task 2: Clean presentation — strip concrete deps, remove binary - -**Files:** -- Modify: `crates/presentation/Cargo.toml` -- Modify: `crates/presentation/src/lib.rs` -- Modify: `crates/presentation/src/state.rs` -- Delete: `crates/presentation/src/main.rs` - -- [ ] **Remove `[[bin]]` table and `src/main.rs` from `crates/presentation/Cargo.toml`:** - -Delete these lines entirely: -```toml -[[bin]] -name = "thoughts" -path = "src/main.rs" -``` - -- [ ] **Strip concrete adapter deps from `crates/presentation/Cargo.toml`:** - -Remove these lines: -```toml -postgres = { workspace = true } -postgres-search = { workspace = true } -postgres-federation = { workspace = true } -activitypub = { workspace = true } -nats = { workspace = true } -async-nats = { workspace = true } -sqlx = { workspace = true } -auth = { workspace = true } -dotenvy = { workspace = true } -tracing-subscriber = { workspace = true } -``` - -Keep these (they belong to the HTTP layer): -```toml -domain = { workspace = true } -application = { workspace = true } -api-types = { workspace = true } -axum = { workspace = true } -tower-http = { workspace = true } -tokio = { workspace = true, features = ["full"] } -serde = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -async-trait = { workspace = true } -sha2 = "0.10" -hex = "0.4" -activitypub-base = { workspace = true } -activitypub_federation = "0.7.0-beta.11" -url = { workspace = true } -utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } -utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } -utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } -``` - -- [ ] **Rewrite `crates/presentation/src/lib.rs`** — remove `build_state`, `NoOpEventPublisher`, and all concrete imports. The file becomes purely module declarations: - -```rust -pub mod errors; -pub mod extractors; -pub mod handlers; -pub mod openapi; -pub mod routes; -pub mod state; -``` - -- [ ] **Remove `fed_config` from `crates/presentation/src/state.rs`:** - -The `AppState` struct currently has `pub fed_config: ApFederationConfig`. Remove that field and its import. The struct becomes: - -```rust -use std::sync::Arc; -use domain::ports::*; - -#[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, -} -``` - -- [ ] **Delete `crates/presentation/src/main.rs`:** - -```bash -rm crates/presentation/src/main.rs -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (bootstrap now owns the binary). - -- [ ] **Verify only bootstrap knows about postgres:** - -```bash -cargo tree -p presentation 2>/dev/null | grep -E "postgres|sqlx|nats|auth" | head -5 || echo "clean" -``` -Expected: `clean` — no concrete adapter deps in presentation. - -- [ ] **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/Cargo.toml \ - crates/presentation/src/lib.rs \ - crates/presentation/src/state.rs -git rm crates/presentation/src/main.rs -git commit -m "refactor(presentation): pure HTTP library — remove build_state, concrete adapter deps, and binary" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `bootstrap/config.rs` reads all env vars into typed `Config` struct (Task 1) -- ✅ `bootstrap/factory.rs` builds all `Arc` adapters from `Config` (Task 1) -- ✅ `bootstrap/main.rs` is thin: read config → factory → serve (Task 1) -- ✅ `presentation` loses `[[bin]]`, `main.rs`, `build_state`, `NoOpEventPublisher` (Task 2) -- ✅ `presentation/Cargo.toml` no longer imports postgres, nats, auth, sqlx, etc. (Task 2) -- ✅ `AppState` has no `fed_config` field — pure `Arc` (Task 2) -- ✅ `cargo tree -p presentation | grep postgres` returns nothing (Task 2) - -**Placeholder scan:** None. - -**Type consistency:** -- `factory::build(cfg: &Config) -> Infrastructure` — matches `main.rs` call -- `Infrastructure { state: AppState, fed_config: ApFederationConfig }` — `state` matches `routes::router().with_state(state)`, `fed_config` matches `routes::router(&infra.fed_config)` -- `AppState` without `fed_config` — `factory.rs` constructs it correctly (no `fed_config:` field) -- `sqlx::migrate!("../adapters/postgres/migrations")` in `factory.rs` — path is relative to `CARGO_MANIFEST_DIR` of `bootstrap` crate (`crates/bootstrap/`), resolves to `crates/adapters/postgres/migrations` ✓ - -**Note on Dockerfile:** The existing `Dockerfile` references the `thoughts` binary. Since `bootstrap/Cargo.toml` uses `[[bin]] name = "thoughts"`, the binary name is unchanged — Dockerfile needs no update. - -**Note on worker:** `crates/worker/` is already a clean composition root — it wires its own deps in `main.rs`. No changes needed there. diff --git a/docs/superpowers/plans/2026-05-14-event-publisher-refactor.md b/docs/superpowers/plans/2026-05-14-event-publisher-refactor.md deleted file mode 100644 index da8ec28..0000000 --- a/docs/superpowers/plans/2026-05-14-event-publisher-refactor.md +++ /dev/null @@ -1,408 +0,0 @@ -# event-publisher Transport Abstraction Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fill `event-publisher` with a `Transport` trait + `EventPublisherAdapter`, strip `NatsEventPublisher` from the `nats` crate and replace it with `NatsTransport` implementing `Transport`, then wire `EventPublisherAdapter::new(NatsTransport::new(client))` in bootstrap — so adding Kafka/Redis later only requires a new transport crate. - -**Architecture:** `event-publisher` defines the abstraction (`Transport` + `EventPublisherAdapter`). `nats` implements `Transport` for NATS (pure bytes: publish/subscribe). `event-publisher` never imports `nats`. `bootstrap` wires them together. `NatsEventConsumer` stays in `nats` — it's transport-specific and will never be shared. - -**Dependency chain after refactor:** -``` -event-publisher → domain, event-payload, serde_json -nats → domain, event-payload, event-publisher, async-nats -bootstrap → event-publisher, nats (+ all others) -``` - ---- - -## File Map - -``` -Modify: crates/adapters/event-publisher/Cargo.toml ← add deps -Modify: crates/adapters/event-publisher/src/lib.rs ← Transport trait + EventPublisherAdapter -Modify: crates/adapters/nats/Cargo.toml ← add event-publisher dep -Modify: crates/adapters/nats/src/lib.rs ← remove NatsEventPublisher, add NatsTransport -Modify: crates/bootstrap/src/factory.rs ← use EventPublisherAdapter -Modify: crates/bootstrap/Cargo.toml ← add event-publisher dep (if missing) -``` - ---- - -### Task 1: Fill event-publisher — Transport trait + EventPublisherAdapter - -**Files:** -- Modify: `crates/adapters/event-publisher/Cargo.toml` -- Modify: `crates/adapters/event-publisher/src/lib.rs` - -- [ ] **Write tests** at the bottom of `crates/adapters/event-publisher/src/lib.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use std::sync::{Arc, Mutex}; - use domain::value_objects::{ThoughtId, UserId}; - - struct SpyTransport { - calls: Arc)>>>, - } - impl SpyTransport { - fn new() -> (Self, Arc)>>>) { - let calls = Arc::new(Mutex::new(vec![])); - (Self { calls: calls.clone() }, calls) - } - } - #[async_trait] - impl Transport for SpyTransport { - async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), domain::errors::DomainError> { - self.calls.lock().unwrap().push((subject.to_string(), bytes.to_vec())); - Ok(()) - } - } - - #[tokio::test] - async fn thought_created_routes_to_correct_subject() { - let (spy, calls) = SpyTransport::new(); - let publisher = EventPublisherAdapter::new(spy); - publisher.publish(&domain::events::DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }).await.unwrap(); - let calls = calls.lock().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, "thoughts.created"); - } - - #[tokio::test] - async fn serialized_payload_is_valid_json() { - let (spy, calls) = SpyTransport::new(); - let publisher = EventPublisherAdapter::new(spy); - publisher.publish(&domain::events::DomainEvent::UserBlocked { - blocker_id: UserId::new(), - blocked_id: UserId::new(), - }).await.unwrap(); - let bytes = &calls.lock().unwrap()[0].1.clone(); - let json: serde_json::Value = serde_json::from_slice(bytes).expect("valid JSON"); - assert_eq!(json["type"], "UserBlocked"); - } -} -``` - -- [ ] **Run:** `cargo test -p event-publisher` — Expected: FAIL (no implementation yet). - -- [ ] **Write `crates/adapters/event-publisher/Cargo.toml`:** - -```toml -[package] -name = "event-publisher" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -event-payload = { workspace = true } -serde_json = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["full"] } -``` - -- [ ] **Write `crates/adapters/event-publisher/src/lib.rs`:** - -```rust -use async_trait::async_trait; -use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; -use event_payload::EventPayload; - -/// Abstraction over any pub/sub transport backend. -/// Implement this for NATS, Kafka, Redis Streams, etc. -/// The adapter calls `publish_bytes(subject, bytes)` — subjects come from `EventPayload::subject()`. -#[async_trait] -pub trait Transport: Send + Sync { - async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>; -} - -/// Routes domain events to a transport backend. -/// -/// Converts: `DomainEvent` → `EventPayload` (via `From`) → JSON bytes → `transport.publish_bytes(subject, bytes)` -/// -/// To swap transports (e.g. NATS → Kafka), replace the `T` at the composition root. -/// This type never needs to change. -pub struct EventPublisherAdapter { - transport: T, -} - -impl EventPublisherAdapter { - pub fn new(transport: T) -> Self { - Self { transport } - } -} - -#[async_trait] -impl EventPublisher for EventPublisherAdapter { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - let payload = EventPayload::from(event); - let subject = payload.subject(); - let bytes = serde_json::to_vec(&payload) - .map_err(|e| DomainError::Internal(e.to_string()))?; - tracing::debug!(subject, "publishing event"); - self.transport.publish_bytes(subject, &bytes).await - } -} -``` - -- [ ] **Run:** `cargo test -p event-publisher` — Expected: 2 tests pass. - -- [ ] **Commit:** - -```bash -git add crates/adapters/event-publisher/ -git commit -m "feat(event-publisher): Transport trait + EventPublisherAdapter for transport-agnostic event routing" -``` - ---- - -### Task 2: Refactor nats — strip NatsEventPublisher, add NatsTransport - -**Files:** -- Modify: `crates/adapters/nats/Cargo.toml` -- Modify: `crates/adapters/nats/src/lib.rs` - -- [ ] **Add `event-publisher` to `crates/adapters/nats/Cargo.toml`:** - -```toml -event-publisher = { workspace = true } -``` - -- [ ] **Rewrite `crates/adapters/nats/src/lib.rs`** — remove `NatsEventPublisher`, add `NatsTransport`: - -```rust -use async_trait::async_trait; -use domain::{ - errors::DomainError, - events::{DomainEvent, EventEnvelope}, - ports::EventConsumer, -}; -use event_payload::EventPayload; -use event_publisher::Transport; -use futures::stream::BoxStream; - -// ── NatsTransport — raw NATS publish backend ──────────────────────────────── - -pub struct NatsTransport { - client: async_nats::Client, -} - -impl NatsTransport { - pub fn new(client: async_nats::Client) -> Self { Self { client } } -} - -#[async_trait] -impl Transport for NatsTransport { - async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { - self.client - .publish(subject, bytes.to_vec().into()) - .await - .map_err(|e| DomainError::Internal(e.to_string())) - } -} - -// ── NatsEventConsumer — subscribes and yields EventEnvelopes ──────────────── - -pub struct NatsEventConsumer { - client: async_nats::Client, -} - -impl NatsEventConsumer { - pub fn new(client: async_nats::Client) -> Self { Self { client } } -} - -impl EventConsumer for NatsEventConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let client = self.client.clone(); - Box::pin(async_stream::try_stream! { - let mut sub = client - .subscribe(">") - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - use futures::StreamExt; - while let Some(msg) = sub.next().await { - let payload = match serde_json::from_slice::(&msg.payload) { - Ok(p) => p, - Err(e) => { - tracing::warn!("failed to deserialize event payload: {e}"); - continue; - } - }; - let event = match DomainEvent::try_from(payload) { - Ok(e) => e, - Err(e) => { - tracing::warn!("failed to convert payload to domain event: {e}"); - continue; - } - }; - yield EventEnvelope { - event, - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use domain::value_objects::{LikeId, ThoughtId, UserId}; - - #[test] - fn payload_from_domain_event_has_correct_subject() { - let event = DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }; - let payload = EventPayload::from(&event); - assert_eq!(payload.subject(), "thoughts.created"); - } - - #[test] - fn domain_event_roundtrip_via_payload() { - let uid = UserId::new(); - let tid = ThoughtId::new(); - let event = DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: uid.clone(), - thought_id: tid.clone(), - }; - let payload = EventPayload::from(&event); - let back = DomainEvent::try_from(payload).unwrap(); - if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back { - assert_eq!(user_id, uid); - assert_eq!(thought_id, tid); - } else { - panic!("wrong variant"); - } - } -} -``` - -- [ ] **Run:** `cargo test -p nats` — Expected: 2 tests pass. - -- [ ] **Run:** `cargo check --workspace` — Expected: one error in `bootstrap` (uses removed `NatsEventPublisher`) — this is expected and fixed in Task 3. - -- [ ] **Commit:** - -```bash -git add crates/adapters/nats/ -git commit -m "refactor(nats): strip NatsEventPublisher, add NatsTransport implementing Transport" -``` - ---- - -### Task 3: Wire EventPublisherAdapter in bootstrap - -**Files:** -- Modify: `crates/bootstrap/Cargo.toml` -- Modify: `crates/bootstrap/src/factory.rs` - -- [ ] **Add `event-publisher` to `crates/bootstrap/Cargo.toml`:** - -```toml -event-publisher = { workspace = true } -``` - -- [ ] **Update `crates/bootstrap/src/factory.rs`** — find the NATS event publisher section and replace: - -Find (in the `build` function): -```rust -Arc::new(nats::NatsEventPublisher::new(client)) -``` - -Replace with: -```rust -Arc::new(event_publisher::EventPublisherAdapter::new(nats::NatsTransport::new(client))) -``` - -The `use` imports at the top of `factory.rs` need `event_publisher` in scope. Add: -```rust -use event_publisher::EventPublisherAdapter; -``` - -The `NoOpEventPublisher` struct and its `impl EventPublisher` stays in `factory.rs` — it's the fallback when NATS is unavailable and lives correctly in the composition root. - -- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Run full test suite:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 -``` - -Expected: all tests pass (including new event-publisher tests). - -- [ ] **Smoke test:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ -JWT_SECRET=dev BASE_URL=http://localhost:3000 \ -RUST_LOG=info cargo run -p bootstrap & -sleep 3 -curl -s http://localhost:3000/health | jq . -kill %1 2>/dev/null -``` - -Expected: `{"status":"ok","db":"connected"}`. - -- [ ] **Commit:** - -```bash -git add crates/bootstrap/ -git commit -m "feat(bootstrap): wire EventPublisherAdapter — transport-agnostic event publishing" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `Transport` trait in `event-publisher` with `publish_bytes(subject, bytes)` (Task 1) -- ✅ `EventPublisherAdapter` implements `EventPublisher` (Task 1) -- ✅ 2 tests: correct subject routing, valid JSON serialization (Task 1) -- ✅ `NatsEventPublisher` removed from `nats` (Task 2) -- ✅ `NatsTransport` implements `Transport` for NATS (Task 2) -- ✅ `NatsEventConsumer` unchanged — stays in `nats` (Task 2) -- ✅ `bootstrap` wires `EventPublisherAdapter::new(NatsTransport::new(client))` (Task 3) -- ✅ `NoOpEventPublisher` stays in `factory.rs` as fallback (Task 3) - -**Placeholder scan:** None. - -**Type consistency:** -- `EventPublisherAdapter` — `NatsTransport` implements `Transport`, `EventPublisherAdapter` implements `EventPublisher` ✓ -- `event_publisher::Transport` imported in `nats/src/lib.rs` — `nats` depends on `event-publisher` ✓ -- `factory.rs` uses `event_publisher::EventPublisherAdapter` and `nats::NatsTransport` — both in bootstrap deps ✓ - -**Adding Kafka later:** -```toml -# kafka/Cargo.toml -[dependencies] -event-publisher = { workspace = true } -rdkafka = "..." -``` -```rust -// kafka/src/lib.rs -pub struct KafkaTransport { ... } -#[async_trait] impl Transport for KafkaTransport { ... } -``` -```rust -// bootstrap/src/factory.rs — only this line changes: -Arc::new(EventPublisherAdapter::new(KafkaTransport::new(...))) -``` diff --git a/docs/superpowers/plans/2026-05-14-event-transport-rename.md b/docs/superpowers/plans/2026-05-14-event-transport-rename.md deleted file mode 100644 index 620bb79..0000000 --- a/docs/superpowers/plans/2026-05-14-event-transport-rename.md +++ /dev/null @@ -1,483 +0,0 @@ -# event-transport Rename + Consumer Abstraction Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rename `event-publisher` → `event-transport` and add the symmetric consumer abstraction (`MessageSource` trait + `EventConsumerAdapter`) so both publish and subscribe are transport-agnostic. - -**Architecture after this plan:** -``` -event-transport/ ← Transport + EventPublisherAdapter (existing) - ← MessageSource + EventConsumerAdapter (new) - ← RawMessage { subject, payload, ack, nack } (new) - -nats/ ← NatsTransport (existing, implements Transport) - ← NatsMessageSource (new, implements MessageSource) - ← NatsEventConsumer removed - -worker/ ← EventConsumerAdapter::new(NatsMessageSource::new(client)) -``` - -**Dependency chain:** -``` -event-transport → domain, event-payload, serde_json, async-trait -nats → domain, event-payload, event-transport, async-nats -worker → domain, nats, event-transport, postgres -``` - ---- - -## File Map - -``` -Rename: crates/adapters/event-publisher/ → crates/adapters/event-transport/ -Modify: Cargo.toml (root) ← update member path + workspace dep name -Modify: crates/adapters/event-transport/Cargo.toml ← name = "event-transport" -Modify: crates/adapters/nats/Cargo.toml ← event-publisher → event-transport -Modify: crates/adapters/nats/src/lib.rs ← use event_transport; add NatsMessageSource; remove NatsEventConsumer -Modify: crates/bootstrap/Cargo.toml ← event-publisher → event-transport -Modify: crates/bootstrap/src/factory.rs ← use event_transport; update EventConsumerAdapter wiring -Modify: crates/worker/Cargo.toml ← add event-transport dep -Modify: crates/worker/src/main.rs ← EventConsumerAdapter -Modify: crates/adapters/event-transport/src/lib.rs ← add RawMessage + MessageSource + EventConsumerAdapter -``` - ---- - -### Task 1: Rename crate + update all references - -**Files:** root `Cargo.toml`, `event-publisher/Cargo.toml` (renamed), `nats/Cargo.toml`, `bootstrap/Cargo.toml`, `nats/src/lib.rs`, `bootstrap/src/factory.rs` - -- [ ] **Rename the directory:** - -```bash -git mv crates/adapters/event-publisher crates/adapters/event-transport -``` - -- [ ] **Update `crates/adapters/event-transport/Cargo.toml`** — change the package name: - -```toml -[package] -name = "event-transport" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -event-payload = { workspace = true } -serde_json = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["full"] } -``` - -- [ ] **Update root `Cargo.toml`:** - -In `[workspace] members`, change: -```toml -"crates/adapters/event-publisher", -``` -to: -```toml -"crates/adapters/event-transport", -``` - -In `[workspace.dependencies]`, change: -```toml -event-publisher = { path = "crates/adapters/event-publisher" } -``` -to: -```toml -event-transport = { path = "crates/adapters/event-transport" } -``` - -- [ ] **Update `crates/adapters/nats/Cargo.toml`:** - -Change `event-publisher = { workspace = true }` to `event-transport = { workspace = true }`. - -- [ ] **Update `crates/adapters/nats/src/lib.rs`:** - -Change `use event_publisher::Transport;` to `use event_transport::Transport;`. - -- [ ] **Update `crates/bootstrap/Cargo.toml`:** - -Change `event-publisher = { workspace = true }` to `event-transport = { workspace = true }`. - -- [ ] **Update `crates/bootstrap/src/factory.rs`:** - -Change `use event_publisher::EventPublisherAdapter;` to `use event_transport::EventPublisherAdapter;`. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Run:** `cargo test -p event-transport` — Expected: 2 tests pass (same tests as before, just crate renamed). - -- [ ] **Commit:** - -```bash -git add Cargo.toml \ - crates/adapters/event-transport/ \ - crates/adapters/nats/Cargo.toml \ - crates/adapters/nats/src/lib.rs \ - crates/bootstrap/Cargo.toml \ - crates/bootstrap/src/factory.rs -git commit -m "refactor: rename event-publisher → event-transport" -``` - ---- - -### Task 2: Add MessageSource + EventConsumerAdapter to event-transport - -**Files:** -- Modify: `crates/adapters/event-transport/src/lib.rs` - -- [ ] **Write failing tests** — append to the test module in `src/lib.rs`: - -```rust - #[tokio::test] - async fn consumer_adapter_deserializes_and_yields_event() { - use domain::value_objects::ThoughtId; - use futures::StreamExt; - - // Produce a serialized EventPayload for ThoughtCreated - let event = DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }; - let payload = EventPayload::from(&event); - let bytes = serde_json::to_vec(&payload).unwrap(); - - // A MessageSource that yields one message then ends - struct OneMessageSource { bytes: Vec } - #[async_trait] - impl MessageSource for OneMessageSource { - fn messages(&self) -> futures::stream::BoxStream<'_, Result> { - let msg = RawMessage { - subject: "thoughts.created".to_string(), - payload: self.bytes.clone(), - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - Box::pin(futures::stream::once(async { Ok(msg) })) - } - } - - let adapter = EventConsumerAdapter::new(OneMessageSource { bytes }); - let mut stream = adapter.consume(); - let envelope = stream.next().await.unwrap().unwrap(); - assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. })); - } - - #[tokio::test] - async fn consumer_adapter_skips_invalid_payloads() { - use futures::StreamExt; - - struct BadMessageSource; - #[async_trait] - impl MessageSource for BadMessageSource { - fn messages(&self) -> futures::stream::BoxStream<'_, Result> { - let msg = RawMessage { - subject: "bad".to_string(), - payload: b"not valid json".to_vec(), - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - Box::pin(futures::stream::once(async { Ok(msg) })) - } - } - - let adapter = EventConsumerAdapter::new(BadMessageSource); - let mut stream = adapter.consume(); - // Invalid JSON should be skipped — stream ends with no items - assert!(stream.next().await.is_none()); - } -``` - -- [ ] **Run:** `cargo test -p event-transport` — Expected: FAIL (MessageSource, RawMessage, EventConsumerAdapter not defined). - -- [ ] **Add to `crates/adapters/event-transport/src/lib.rs`** — append after the existing `EventPublisherAdapter` impl and before `#[cfg(test)]`: - -```rust -use domain::{events::EventEnvelope, ports::EventConsumer}; -use futures::stream::BoxStream; - -/// A raw inbound message from a transport backend. -/// `ack` and `nack` are transport-level acknowledgements (e.g. Kafka offset commit). -/// For at-most-once transports (basic NATS), both are no-ops. -pub struct RawMessage { - pub subject: String, - pub payload: Vec, - pub ack: Box, - pub nack: Box, -} - -/// Abstraction over any subscribe/consume backend. -/// Implement this for NATS, Kafka, Redis Streams, etc. -pub trait MessageSource: Send + Sync { - fn messages(&self) -> BoxStream<'_, Result>; -} - -/// Deserializes raw transport messages into domain `EventEnvelope`s. -/// -/// Converts: `RawMessage.payload` → `EventPayload` → `DomainEvent` → `EventEnvelope` -/// -/// Invalid or unknown messages are skipped with a warning — the stream continues. -pub struct EventConsumerAdapter { - source: S, -} - -impl EventConsumerAdapter { - pub fn new(source: S) -> Self { Self { source } } -} - -impl EventConsumer for EventConsumerAdapter { - fn consume(&self) -> BoxStream<'_, Result> { - use futures::StreamExt; - let stream = self.source.messages(); - Box::pin(stream.filter_map(|result| async move { - match result { - Err(e) => { - tracing::warn!("transport error: {e}"); - None - } - Ok(msg) => { - let payload = match serde_json::from_slice::(&msg.payload) { - Ok(p) => p, - Err(e) => { - tracing::warn!("failed to deserialize event payload: {e}"); - return None; - } - }; - let event = match DomainEvent::try_from(payload) { - Ok(e) => e, - Err(e) => { - tracing::warn!("unknown event type: {e}"); - return None; - } - }; - Some(Ok(EventEnvelope { - event, - ack: msg.ack, - nack: msg.nack, - })) - } - } - })) - } -} -``` - -Note: the existing imports at the top of `lib.rs` already have `use domain::...` — add `EventEnvelope` and `EventConsumer` to those imports. Also add `futures::stream::BoxStream` if not already present. - -Also add `futures = { workspace = true }` to `event-transport/Cargo.toml` dependencies (needed for `BoxStream` and `StreamExt`). - -- [ ] **Run:** `cargo test -p event-transport` — Expected: 4 tests pass (2 existing + 2 new). - -- [ ] **Commit:** - -```bash -git add crates/adapters/event-transport/ -git commit -m "feat(event-transport): MessageSource trait + EventConsumerAdapter for transport-agnostic consuming" -``` - ---- - -### Task 3: nats — add NatsMessageSource, remove NatsEventConsumer - -**Files:** -- Modify: `crates/adapters/nats/src/lib.rs` - -- [ ] **Rewrite `crates/adapters/nats/src/lib.rs`** — remove `NatsEventConsumer`, add `NatsMessageSource`: - -```rust -use async_trait::async_trait; -use domain::errors::DomainError; -use event_payload::EventPayload; -use event_transport::{MessageSource, RawMessage, Transport}; -use futures::stream::BoxStream; - -// ── NatsTransport — raw NATS publish backend ──────────────────────────────── - -pub struct NatsTransport { - client: async_nats::Client, -} - -impl NatsTransport { - pub fn new(client: async_nats::Client) -> Self { Self { client } } -} - -#[async_trait] -impl Transport for NatsTransport { - async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { - self.client - .publish(subject, bytes.to_vec().into()) - .await - .map_err(|e| DomainError::Internal(e.to_string())) - } -} - -// ── NatsMessageSource — raw NATS subscribe backend ────────────────────────── - -pub struct NatsMessageSource { - client: async_nats::Client, -} - -impl NatsMessageSource { - pub fn new(client: async_nats::Client) -> Self { Self { client } } -} - -impl MessageSource for NatsMessageSource { - fn messages(&self) -> BoxStream<'_, Result> { - let client = self.client.clone(); - Box::pin(async_stream::try_stream! { - let mut sub = client - .subscribe(">") - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - use futures::StreamExt; - while let Some(msg) = sub.next().await { - let subject = msg.subject.to_string(); - let payload = msg.payload.to_vec(); - // Basic NATS: at-most-once delivery — ack/nack are no-ops. - // Replace with JetStream for at-least-once delivery. - yield RawMessage { - subject, - payload, - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use domain::{events::DomainEvent, value_objects::{LikeId, ThoughtId, UserId}}; - - #[test] - fn payload_from_domain_event_has_correct_subject() { - let event = DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }; - let payload = EventPayload::from(&event); - assert_eq!(payload.subject(), "thoughts.created"); - } - - #[test] - fn domain_event_roundtrip_via_payload() { - let uid = UserId::new(); - let tid = ThoughtId::new(); - let event = DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: uid.clone(), - thought_id: tid.clone(), - }; - let payload = EventPayload::from(&event); - let back = DomainEvent::try_from(payload).unwrap(); - if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back { - assert_eq!(user_id, uid); - assert_eq!(thought_id, tid); - } else { - panic!("wrong variant"); - } - } -} -``` - -- [ ] **Run:** `cargo test -p nats` — Expected: 2 tests pass. - -- [ ] **Run:** `cargo check --workspace` — Expected: one error in `worker` (uses removed `NatsEventConsumer`). That's expected — fixed in Task 4. - -- [ ] **Commit:** - -```bash -git add crates/adapters/nats/src/lib.rs -git commit -m "refactor(nats): replace NatsEventConsumer with NatsMessageSource implementing MessageSource" -``` - ---- - -### Task 4: Update worker + full verification - -**Files:** -- Modify: `crates/worker/Cargo.toml` -- Modify: `crates/worker/src/main.rs` - -- [ ] **Add `event-transport = { workspace = true }` to `crates/worker/Cargo.toml`.** - -- [ ] **Update `crates/worker/src/main.rs`** — find and update the consumer creation. - -Current code in `main.rs`: -```rust -let consumer = nats::NatsEventConsumer::new(nats_client); -``` - -Replace with: -```rust -use event_transport::EventConsumerAdapter; -use nats::NatsMessageSource; -let consumer = EventConsumerAdapter::new(NatsMessageSource::new(nats_client)); -``` - -Also add the `use` statements at the top of `main.rs` alongside existing imports. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Run full test suite:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 -``` - -Expected: all tests pass (79 existing + 2 new event-transport consumer tests = 81+). - -- [ ] **Commit:** - -```bash -git add crates/worker/ -git commit -m "feat(worker): use EventConsumerAdapter — transport-agnostic consuming" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `event-publisher` renamed to `event-transport` everywhere (Task 1) -- ✅ `RawMessage { subject, payload, ack, nack }` in `event-transport` (Task 2) -- ✅ `MessageSource` trait with `messages() -> BoxStream` (Task 2) -- ✅ `EventConsumerAdapter` implementing `EventConsumer` (Task 2) -- ✅ Invalid messages skipped with warning, stream continues (Task 2) -- ✅ 2 new tests: valid deserialization + invalid JSON skip (Task 2) -- ✅ `NatsEventConsumer` removed from nats (Task 3) -- ✅ `NatsMessageSource` implementing `MessageSource` added to nats (Task 3) -- ✅ Worker uses `EventConsumerAdapter::new(NatsMessageSource::new(client))` (Task 4) - -**Adding Kafka later:** -```toml -# kafka/Cargo.toml: event-transport = { workspace = true } -``` -```rust -// kafka/src/lib.rs -pub struct KafkaMessageSource { ... } -impl MessageSource for KafkaMessageSource { ... } // yields RawMessage + real ack/nack - -pub struct KafkaTransport { ... } -impl Transport for KafkaTransport { ... } -``` -```rust -// bootstrap/src/factory.rs — two lines change: -EventPublisherAdapter::new(KafkaTransport::new(...)) -EventConsumerAdapter::new(KafkaMessageSource::new(...)) -``` - -**Type consistency:** -- `EventConsumerAdapter` — `NatsMessageSource` implements `MessageSource`, adapter implements `EventConsumer` ✓ -- `RawMessage.ack` / `.nack` transferred to `EventEnvelope.ack` / `.nack` in consumer adapter ✓ -- `event_transport::` (underscore) is the Rust module name for `event-transport` (dash) crate ✓ diff --git a/docs/superpowers/plans/2026-05-14-federation-follow-ups.md b/docs/superpowers/plans/2026-05-14-federation-follow-ups.md deleted file mode 100644 index ca329be..0000000 --- a/docs/superpowers/plans/2026-05-14-federation-follow-ups.md +++ /dev/null @@ -1,350 +0,0 @@ -# Federation Follow-ups Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Two targeted follow-ups from the federation handler implementation: (1) handle `BoostRemoved` → `Undo(Announce)` fan-out, which was a known missing feature; (2) extract the repeated follower-filtering block in `ActivityPubService` into a private helper to eliminate duplication across 6 broadcast methods. - -**Architecture:** Both changes are additive and self-contained. Task 1 touches `domain/ports.rs`, `activitypub-base/src/service.rs`, and `application/src/services/federation_event.rs`. Task 2 touches only `activitypub-base/src/service.rs`. - ---- - -## File Map - -``` -Task 1: - Modify: crates/domain/src/ports.rs ← add broadcast_undo_announce to OutboundFederationPort - Modify: crates/adapters/activitypub-base/src/service.rs ← broadcast_undo_announce_to_followers + impl - Modify: crates/application/src/services/federation_event.rs ← handle BoostRemoved + tests - -Task 2: - Modify: crates/adapters/activitypub-base/src/service.rs ← extract accepted_follower_inboxes helper -``` - ---- - -### Task 1: BoostRemoved → Undo(Announce) - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/adapters/activitypub-base/src/service.rs` -- Modify: `crates/application/src/services/federation_event.rs` - -#### Step A: Add `broadcast_undo_announce` to `OutboundFederationPort` - -- [ ] In `crates/domain/src/ports.rs`, add one method to `OutboundFederationPort` after `broadcast_announce`: - -```rust -/// Fan out an Undo(Announce) to followers when a boost is removed. -async fn broadcast_undo_announce( - &self, - booster_user_id: &UserId, - object_ap_id: &str, -) -> Result<(), DomainError>; -``` - -- [ ] **Run:** `cargo check -p domain` — Expected: error in activitypub-base (trait impl missing method). This is expected. - -#### Step B: Add `broadcast_undo_announce_to_followers` to `ActivityPubService` and implement the port method - -- [ ] In `crates/adapters/activitypub-base/src/service.rs`, add `broadcast_undo_announce_to_followers` to `impl ActivityPubService` — insert after `broadcast_announce_to_followers`: - -```rust -/// Fan out an Undo(Announce) activity to all accepted followers. -pub async fn broadcast_undo_announce_to_followers( - &self, - local_user_id: uuid::Uuid, - object_ap_id: url::Url, -) -> anyhow::Result<()> { - let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let followers = data.federation_repo.get_followers(local_user_id).await?; - let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); - let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); - let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); - let blocked_domain_set: std::collections::HashSet = - blocked_domains.into_iter().map(|d| d.domain).collect(); - - let accepted: Vec<_> = followers - .into_iter() - .filter(|f| f.status == FollowerStatus::Accepted) - .filter(|f| !blocked_set.contains(&f.actor.url)) - .filter(|f| { - let domain = url::Url::parse(&f.actor.inbox_url) - .ok() - .and_then(|u| u.host_str().map(|s| s.to_string())) - .unwrap_or_default(); - !blocked_domain_set.contains(&domain) - }) - .collect(); - - if accepted.is_empty() { - return Ok(()); - } - - let undo_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; - let undo = crate::activities::UndoActivity { - id: undo_id, - kind: Default::default(), - actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::json!({ - "type": "Announce", - "actor": local_actor.ap_id.to_string(), - "object": object_ap_id.to_string(), - }), - }; - - let inboxes = collect_inboxes(&accepted); - let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( - &activitypub_federation::protocol::context::WithContext::new_default(undo), - &local_actor, - inboxes, - &data, - ) - .await?; - let failures = send_with_retry(sends, &data).await; - if !failures.is_empty() { - tracing::warn!(count = failures.len(), "some Undo(Announce) deliveries failed"); - } - Ok(()) -} -``` - -- [ ] Add `broadcast_undo_announce` to the `impl domain::ports::OutboundFederationPort for ActivityPubService` block: - -```rust -async fn broadcast_undo_announce( - &self, - booster_user_id: &domain::value_objects::UserId, - object_ap_id: &str, -) -> Result<(), domain::errors::DomainError> { - let user_uuid = booster_user_id.as_uuid(); - let ap_id = url::Url::parse(object_ap_id) - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; - self.broadcast_undo_announce_to_followers(user_uuid, ap_id) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) -} -``` - -- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors. -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -#### Step C: Handle `BoostRemoved` in `FederationEventService` - -- [ ] **Write failing test** first — add to the `#[cfg(test)] mod tests` block in `crates/application/src/services/federation_event.rs`: - -```rust -#[tokio::test] -async fn boost_removed_sends_undo_announce_for_local_thought() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostRemoved { - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let announced = spy.announced.lock().unwrap(); - assert_eq!(announced.len(), 1); - assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id)); -} - -#[tokio::test] -async fn boost_removed_sends_undo_announce_for_remote_thought() { - let store = TestStore::default(); - let alice = alice(); - let mut thought = local_thought(alice.id.clone()); - thought.local = false; - thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostRemoved { - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let announced = spy.announced.lock().unwrap(); - assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/456"); -} -``` - -NOTE: The `SpyPort` tracks `broadcast_undo_announce` calls in the same `announced` vec as `broadcast_announce` (or a new `undo_announced` vec — your choice, but be consistent in both the spy and the assertions). - -Actually, use a separate `undo_announced` vec for clarity: - -```rust -#[derive(Default)] -struct SpyPort { - created: Mutex>, - deleted: Mutex>, - updated: Mutex>, - announced: Mutex>, - undo_announced: Mutex>, -} -``` - -And add the impl method: -```rust -async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { - self.undo_announced.lock().unwrap().push(ap_id.to_string()); - Ok(()) -} -``` - -Update the test assertions to use `spy.undo_announced`. - -- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: 2 new tests FAIL (not implemented). - -- [ ] **Add `BoostRemoved` arm** to `FederationEventService::process` — insert after the `BoostAdded` arm: - -```rust -DomainEvent::BoostRemoved { user_id, thought_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - let object_ap_id = thought.ap_id.clone().unwrap_or_else(|| { - format!("{}/thoughts/{}", self.base_url, thought_id) - }); - self.ap.broadcast_undo_announce(user_id, &object_ap_id).await -} -``` - -- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: all tests pass (now 13). - -- [ ] **Run:** `cargo test --workspace` — Expected: only pre-existing postgres DB failures (require live database). - -- [ ] **Commit:** - -```bash -git add crates/domain/src/ports.rs crates/adapters/activitypub-base/src/service.rs crates/application/src/services/federation_event.rs -git commit -m "feat: BoostRemoved → Undo(Announce) fan-out via OutboundFederationPort" -``` - ---- - -### Task 2: Follower-filtering DRY extraction in activitypub-base - -**Files:** -- Modify: `crates/adapters/activitypub-base/src/service.rs` - -The repeated 20-line follower-filtering block appears in 7 methods. Extract it into a private async helper, then call it from the 6 content-broadcast methods. Leave `broadcast_actor_update` alone — it uses different filtering (no blocked-actor/domain check). - -**Methods to update:** `broadcast_to_followers`, `broadcast_delete_to_followers`, `broadcast_update_to_followers`, `broadcast_add_to_followers`, `broadcast_undo_add_to_followers`, `broadcast_announce_to_followers`, `broadcast_undo_announce_to_followers`. - -**Leave unchanged:** `broadcast_actor_update` (filters only on `FollowerStatus::Accepted`, no blocked checks). - -- [ ] **Add private helper** to `impl ActivityPubService` — insert near the top of the impl block, after `request_data`: - -```rust -/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers, -/// excluding blocked actors and blocked domains. Returns `None` if there are -/// no eligible followers (caller should early-return `Ok(())`). -async fn accepted_follower_inboxes( - &self, - data: &activitypub_federation::config::Data, - local_user_id: uuid::Uuid, -) -> anyhow::Result)>> { - let local_actor = get_local_actor(local_user_id, data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let followers = data.federation_repo.get_followers(local_user_id).await?; - let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); - let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); - let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); - let blocked_domain_set: std::collections::HashSet = - blocked_domains.into_iter().map(|d| d.domain).collect(); - - let accepted: Vec<_> = followers - .into_iter() - .filter(|f| f.status == FollowerStatus::Accepted) - .filter(|f| !blocked_set.contains(&f.actor.url)) - .filter(|f| { - let domain = url::Url::parse(&f.actor.inbox_url) - .ok() - .and_then(|u| u.host_str().map(|s| s.to_string())) - .unwrap_or_default(); - !blocked_domain_set.contains(&domain) - }) - .collect(); - - if accepted.is_empty() { - return Ok(None); - } - - Ok(Some((local_actor, collect_inboxes(&accepted)))) -} -``` - -- [ ] **Refactor each of the 7 methods** to use `accepted_follower_inboxes`. - -For each method, replace the block that: -1. Gets `local_actor` -2. Gets followers + filtered inboxes - -with: -```rust -let data = self.federation_config.to_request_data(); -let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { - return Ok(()); -}; -``` - -Then use `local_actor` and `inboxes` directly in the activity construction (same as before). - -The 7 methods are at these line numbers (before refactor — check actual lines in the file): -- `broadcast_announce_to_followers` -- `broadcast_undo_announce_to_followers` (just added in Task 1) -- `broadcast_to_followers` -- `broadcast_delete_to_followers` -- `broadcast_update_to_followers` -- `broadcast_add_to_followers` -- `broadcast_undo_add_to_followers` - -- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Run:** `cargo test --workspace` — Expected: same result as before (pre-existing postgres failures only). - -- [ ] **Commit:** - -```bash -git add crates/adapters/activitypub-base/src/service.rs -git commit -m "refactor(activitypub-base): extract accepted_follower_inboxes helper — eliminate 7x duplicated filtering block" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `broadcast_undo_announce` added to `OutboundFederationPort` (Task 1) -- ✅ `broadcast_undo_announce_to_followers` sends `Undo { object: { type: "Announce", actor, object } }` to accepted, non-blocked followers (Task 1) -- ✅ `FederationEventService` handles `BoostRemoved` with same ap_id construction as `BoostAdded` (Task 1) -- ✅ 2 tests: local thought URL constructed, remote thought uses ap_id (Task 1) -- ✅ `SpyPort` has separate `undo_announced` vec (Task 1) -- ✅ `accepted_follower_inboxes` helper extracts the 20-line filtering block (Task 2) -- ✅ Helper used in 7 content-broadcast methods (Task 2) -- ✅ `broadcast_actor_update` NOT touched — it uses different filtering (Task 2) - -**Placeholder scan:** None. - -**Type consistency:** -- `UndoActivity` is already defined in `activities.rs` with `object: serde_json::Value` — no new activity type needed -- `broadcast_undo_announce_to_followers(uuid::Uuid, url::Url)` — same signature pattern as `broadcast_announce_to_followers` -- `accepted_follower_inboxes` returns `Option<(DbActor, Vec)>` — caller destructures with `let Some(...) = ... else { return Ok(()) }` diff --git a/docs/superpowers/plans/2026-05-14-federation-handler.md b/docs/superpowers/plans/2026-05-14-federation-handler.md deleted file mode 100644 index 8a17ed8..0000000 --- a/docs/superpowers/plans/2026-05-14-federation-handler.md +++ /dev/null @@ -1,1161 +0,0 @@ -# Federation Handler Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the `FederationHandler` stub with a real implementation that fans out content events (ThoughtCreated/Deleted/Updated, BoostAdded) as ActivityPub activities, while simultaneously refactoring both worker handlers to be thin adapters over application-layer event services. - -**Architecture:** Domain defines `OutboundFederationPort`; application holds `FederationEventService` and `NotificationEventService` (business logic); `activitypub-base`'s `ActivityPubService` implements the port; worker handlers are one-liners that call the services. A new `worker/src/factory.rs` owns all dependency construction; `main.rs` stays tiny. - -**Dependency chain after refactor:** -``` -domain ← application ← worker -domain ← activitypub-base (impl OutboundFederationPort) -bootstrap/worker → postgres, postgres-federation, activitypub, activitypub-base (composition roots only) -``` - -**Events handled in FederationHandler (async fan-out only):** -- `ThoughtCreated` → `Create(Note)` to local-user followers (local thoughts only) -- `ThoughtDeleted` → `Delete(Note)` to followers -- `ThoughtUpdated` → `Update(Note)` to followers -- `BoostAdded` → `Announce` to followers -- All others → no-op (Follow/Accept/Reject/Block dispatched synchronously in HTTP handlers) - ---- - -## File Map - -``` -Modify: crates/domain/src/ports.rs - + OutboundFederationPort trait (4 methods) - -Create: crates/application/src/services/mod.rs -Create: crates/application/src/services/notification_event.rs -Create: crates/application/src/services/federation_event.rs -Modify: crates/application/src/lib.rs - + pub mod services - -Modify: crates/adapters/activitypub-base/src/activities.rs - + to/cc fields on AnnounceActivity - -Modify: crates/adapters/activitypub-base/src/service.rs - + broadcast_announce_to_followers() - + impl OutboundFederationPort for ActivityPubService - -Modify: crates/worker/src/handlers.rs - — remove all business logic, keep thin delegation wrappers - -Create: crates/worker/src/factory.rs - + build() → builds all deps and returns (consumer, handlers) - -Modify: crates/worker/src/main.rs - — call factory::build(), keep event loop only - -Modify: crates/worker/Cargo.toml - + activitypub-base, activitypub, postgres-federation, application -``` - ---- - -### Task 1: OutboundFederationPort in domain - -**Files:** -- Modify: `crates/domain/src/ports.rs` - -- [ ] **Add `OutboundFederationPort` to `crates/domain/src/ports.rs`** — insert after the `ActivityPubRepository` trait: - -```rust -#[async_trait] -pub trait OutboundFederationPort: Send + Sync { - /// Fan out a new local Note to all accepted followers. - async fn broadcast_create( - &self, - author_user_id: &UserId, - thought: &Thought, - author_username: &str, - ) -> Result<(), DomainError>; - - /// Fan out a Delete tombstone for a now-deleted local Note. - /// `thought_ap_id` is pre-constructed by the caller because the thought - /// has already been deleted from the DB when this fires. - async fn broadcast_delete( - &self, - author_user_id: &UserId, - thought_ap_id: &str, - ) -> Result<(), DomainError>; - - /// Fan out an Update(Note) for an edited local thought. - async fn broadcast_update( - &self, - author_user_id: &UserId, - thought: &Thought, - author_username: &str, - ) -> Result<(), DomainError>; - - /// Fan out an Announce(object_ap_id) for a boost. - async fn broadcast_announce( - &self, - booster_user_id: &UserId, - object_ap_id: &str, - ) -> Result<(), DomainError>; -} -``` - -- [ ] **Run:** `cargo check -p domain` — Expected: no errors. - -- [ ] **Commit:** - -```bash -git add crates/domain/src/ports.rs -git commit -m "feat(domain): OutboundFederationPort — thin AP broadcast abstraction" -``` - ---- - -### Task 2: NotificationEventService in application - -**Files:** -- Create: `crates/application/src/services/mod.rs` -- Create: `crates/application/src/services/notification_event.rs` -- Modify: `crates/application/src/lib.rs` - -- [ ] **Write failing tests** at the bottom of `crates/application/src/services/notification_event.rs` (file doesn't exist yet — create it): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::{thought::{Thought, Visibility}, user::User}, - testing::TestStore, - value_objects::*, - }; - use std::sync::Arc; - - fn alice() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - #[tokio::test] - async fn like_creates_notification_for_thought_author() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, - ); - store.thoughts.lock().unwrap().push(thought.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: bob_id, - thought_id: thought.id.clone(), - }).await.unwrap(); - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].notification_type, NotificationType::Like)); - } - - #[tokio::test] - async fn self_like_creates_no_notification() { - let store = TestStore::default(); - let alice = alice(); - let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, - ); - store.thoughts.lock().unwrap().push(thought.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }).await.unwrap(); - assert!(store.notifications.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn follow_accepted_creates_notification() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::FollowAccepted { - follower_id: bob_id, - following_id: alice.id.clone(), - }).await.unwrap(); - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].notification_type, NotificationType::Follow)); - } - - #[tokio::test] - async fn reply_creates_notification_for_original_author() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - let original = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("original").unwrap(), - None, Visibility::Public, None, false, - ); - store.thoughts.lock().unwrap().push(original.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: bob_id, - in_reply_to_id: Some(original.id.clone()), - }).await.unwrap(); - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].notification_type, NotificationType::Reply)); - } - - #[tokio::test] - async fn self_reply_creates_no_notification() { - let store = TestStore::default(); - let alice = alice(); - let original = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("original").unwrap(), - None, Visibility::Public, None, false, - ); - store.thoughts.lock().unwrap().push(original.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: alice.id.clone(), - in_reply_to_id: Some(original.id.clone()), - }).await.unwrap(); - assert!(store.notifications.lock().unwrap().is_empty()); - } -} -``` - -- [ ] **Run:** `cargo test -p application` — Expected: FAIL (no implementation yet). - -- [ ] **Create `crates/application/src/services/notification_event.rs`:** - -```rust -use std::sync::Arc; -use chrono::Utc; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::notification::{Notification, NotificationType}, - ports::{NotificationRepository, ThoughtRepository}, - value_objects::NotificationId, -}; - -pub struct NotificationEventService { - pub thoughts: Arc, - pub notifications: Arc, -} - -impl NotificationEventService { - pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { - match event { - DomainEvent::LikeAdded { like_id: _, user_id, thought_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if thought.user_id == *user_id { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - notification_type: NotificationType::Like, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await - } - DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if thought.user_id == *user_id { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - notification_type: NotificationType::Boost, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await - } - DomainEvent::FollowAccepted { follower_id, following_id } => { - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: following_id.clone(), - notification_type: NotificationType::Follow, - from_user_id: Some(follower_id.clone()), - thought_id: None, - read: false, - created_at: Utc::now(), - }).await - } - DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => { - let reply_to_id = match in_reply_to_id { - Some(id) => id, - None => return Ok(()), - }; - let original = match self.thoughts.find_by_id(reply_to_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if original.user_id == *user_id { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: original.user_id, - notification_type: NotificationType::Reply, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await - } - _ => Ok(()), - } - } -} -``` - -- [ ] **Create `crates/application/src/services/mod.rs`:** - -```rust -pub mod federation_event; -pub mod notification_event; - -pub use federation_event::FederationEventService; -pub use notification_event::NotificationEventService; -``` - -- [ ] **Modify `crates/application/src/lib.rs`** — add `pub mod services;`: - -```rust -pub mod services; -pub mod use_cases; -``` - -- [ ] **Run:** `cargo test -p application` — Expected: 5 notification tests pass. - -- [ ] **Commit:** - -```bash -git add crates/application/ -git commit -m "feat(application): NotificationEventService — move notification business logic out of worker" -``` - ---- - -### Task 3: FederationEventService in application - -**Files:** -- Create: `crates/application/src/services/federation_event.rs` -- Modify: `crates/application/src/services/mod.rs` (re-export) - -- [ ] **Write failing tests** inside `crates/application/src/services/federation_event.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use domain::{ - errors::DomainError, - events::DomainEvent, - models::thought::{Thought, Visibility}, - models::user::User, - ports::OutboundFederationPort, - testing::TestStore, - value_objects::*, - }; - use std::sync::{Arc, Mutex}; - - // ── Spy port ───────────────────────────────────────────────────────────── - - #[derive(Default)] - struct SpyPort { - created: Mutex>, - deleted: Mutex>, - updated: Mutex>, - announced: Mutex>, - } - - #[async_trait] - impl OutboundFederationPort for SpyPort { - async fn broadcast_create(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> { - self.created.lock().unwrap().push(thought.id.clone()); - Ok(()) - } - async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { - self.deleted.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - async fn broadcast_update(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> { - self.updated.lock().unwrap().push(thought.id.clone()); - Ok(()) - } - async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { - self.announced.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - } - - fn alice() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - fn local_thought(author_id: UserId) -> Thought { - Thought::new_local( - ThoughtId::new(), author_id, - Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, - ) - } - - fn svc(store: &TestStore, spy: Arc) -> FederationEventService { - FederationEventService { - thoughts: Arc::new(store.clone()), - users: Arc::new(store.clone()), - ap: spy, - base_url: "https://example.com".to_string(), - } - } - - #[tokio::test] - async fn thought_created_broadcasts_create() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert_eq!(spy.created.lock().unwrap().len(), 1); - assert_eq!(spy.created.lock().unwrap()[0], thought.id); - } - - #[tokio::test] - async fn remote_thought_created_does_not_broadcast() { - let store = TestStore::default(); - let alice = alice(); - // Remote thought: local = false, ap_id = Some(...) - let mut thought = local_thought(alice.id.clone()); - thought.local = false; - thought.ap_id = Some("https://remote.example/notes/1".into()); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() { - let store = TestStore::default(); - let alice = alice(); - let tid = ThoughtId::new(); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtDeleted { - thought_id: tid.clone(), - user_id: alice.id.clone(), - }) - .await - .unwrap(); - - let deleted = spy.deleted.lock().unwrap(); - assert_eq!(deleted.len(), 1); - assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid)); - } - - #[tokio::test] - async fn thought_updated_broadcasts_update() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtUpdated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - }) - .await - .unwrap(); - - assert_eq!(spy.updated.lock().unwrap().len(), 1); - } - - #[tokio::test] - async fn boost_of_local_thought_announces_constructed_url() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); // ap_id = None - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostAdded { - boost_id: BoostId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let announced = spy.announced.lock().unwrap(); - assert_eq!(announced.len(), 1); - assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id)); - } - - #[tokio::test] - async fn boost_of_remote_thought_announces_remote_ap_id() { - let store = TestStore::default(); - let alice = alice(); - let mut thought = local_thought(alice.id.clone()); - thought.local = false; - thought.ap_id = Some("https://mastodon.social/users/bob/statuses/123".into()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostAdded { - boost_id: BoostId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let announced = spy.announced.lock().unwrap(); - assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/123"); - } - - #[tokio::test] - async fn unrelated_events_are_noop() { - let store = TestStore::default(); - let spy = Arc::new(SpyPort::default()); - let svc = svc(&store, spy.clone()); - - svc.process(&DomainEvent::UserBlocked { - blocker_id: UserId::new(), - blocked_id: UserId::new(), - }).await.unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - assert!(spy.deleted.lock().unwrap().is_empty()); - assert!(spy.updated.lock().unwrap().is_empty()); - assert!(spy.announced.lock().unwrap().is_empty()); - } -} -``` - -- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: FAIL (no implementation). - -- [ ] **Write `crates/application/src/services/federation_event.rs`** — full file including tests already added above: - -```rust -use std::sync::Arc; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::thought::Thought, - ports::{OutboundFederationPort, ThoughtRepository, UserRepository}, - value_objects::UserId, -}; - -pub struct FederationEventService { - pub thoughts: Arc, - pub users: Arc, - pub ap: Arc, - pub base_url: String, -} - -impl FederationEventService { - pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { - match event { - DomainEvent::ThoughtCreated { thought_id, user_id, .. } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) if t.local => t, - _ => return Ok(()), - }; - let user = match self.users.find_by_id(user_id).await? { - Some(u) => u, - None => return Ok(()), - }; - self.ap.broadcast_create(user_id, &thought, user.username.as_str()).await - } - - DomainEvent::ThoughtDeleted { thought_id, user_id } => { - let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id); - self.ap.broadcast_delete(user_id, &ap_id).await - } - - DomainEvent::ThoughtUpdated { thought_id, user_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) if t.local => t, - _ => return Ok(()), - }; - let user = match self.users.find_by_id(user_id).await? { - Some(u) => u, - None => return Ok(()), - }; - self.ap.broadcast_update(user_id, &thought, user.username.as_str()).await - } - - DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - let object_ap_id = thought.ap_id.clone().unwrap_or_else(|| { - format!("{}/thoughts/{}", self.base_url, thought_id) - }); - self.ap.broadcast_announce(user_id, &object_ap_id).await - } - - _ => Ok(()), - } - } -} -``` - -- [ ] **Update `crates/application/src/services/mod.rs`** to re-export both services: - -```rust -pub mod federation_event; -pub mod notification_event; - -pub use federation_event::FederationEventService; -pub use notification_event::NotificationEventService; -``` - -- [ ] **Run:** `cargo test -p application` — Expected: all 12 tests pass (5 notification + 7 federation). - -- [ ] **Commit:** - -```bash -git add crates/application/src/services/federation_event.rs crates/application/src/services/mod.rs -git commit -m "feat(application): FederationEventService — content fan-out business logic" -``` - ---- - -### Task 4: AnnounceActivity to/cc + impl OutboundFederationPort for ActivityPubService - -**Files:** -- Modify: `crates/adapters/activitypub-base/src/activities.rs` -- Modify: `crates/adapters/activitypub-base/src/service.rs` - -- [ ] **Add `to`/`cc` to `AnnounceActivity`** in `crates/adapters/activitypub-base/src/activities.rs` — replace the struct definition (fields only; leave `impl Activity` intact): - -```rust -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AnnounceActivity { - pub(crate) id: Url, - #[serde(rename = "type", default)] - pub(crate) kind: AnnounceType, - pub(crate) actor: ObjectId, - pub(crate) object: Url, - pub(crate) published: Option>, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub(crate) to: Vec, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub(crate) cc: Vec, -} -``` - -- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors (fields are optional in deserialization due to `default`). - -- [ ] **Add `broadcast_announce_to_followers`** to `ActivityPubService` in `crates/adapters/activitypub-base/src/service.rs` — insert before the `follow` method: - -```rust -/// Fan out an Announce activity to all accepted followers. -pub async fn broadcast_announce_to_followers( - &self, - local_user_id: uuid::Uuid, - object_ap_id: url::Url, -) -> anyhow::Result<()> { - let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let followers = data.federation_repo.get_followers(local_user_id).await?; - let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); - let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); - let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); - let blocked_domain_set: std::collections::HashSet = - blocked_domains.into_iter().map(|d| d.domain).collect(); - - let accepted: Vec<_> = followers - .into_iter() - .filter(|f| f.status == FollowerStatus::Accepted) - .filter(|f| !blocked_set.contains(&f.actor.url)) - .filter(|f| { - let domain = url::Url::parse(&f.actor.inbox_url) - .ok() - .and_then(|u| u.host_str().map(|s| s.to_string())) - .unwrap_or_default(); - !blocked_domain_set.contains(&domain) - }) - .collect(); - - if accepted.is_empty() { - return Ok(()); - } - - let announce = AnnounceActivity { - id: crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, - kind: Default::default(), - actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), - object: object_ap_id, - published: Some(chrono::Utc::now()), - to: vec![crate::urls::AS_PUBLIC.to_string()], - cc: vec![local_actor.followers_url.to_string()], - }; - - let inboxes = collect_inboxes(&accepted); - let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( - &activitypub_federation::protocol::context::WithContext::new_default(announce), - &local_actor, - inboxes, - &data, - ) - .await?; - let failures = send_with_retry(sends, &data).await; - if !failures.is_empty() { - tracing::warn!(count = failures.len(), "some Announce deliveries failed"); - } - Ok(()) -} -``` - -- [ ] **Add `impl OutboundFederationPort for ActivityPubService`** at the bottom of `crates/adapters/activitypub-base/src/service.rs`, after the existing `impl ActivityPubService` block: - -```rust -#[async_trait::async_trait] -impl domain::ports::OutboundFederationPort for ActivityPubService { - async fn broadcast_create( - &self, - author_user_id: &domain::value_objects::UserId, - thought: &domain::models::thought::Thought, - author_username: &str, - ) -> Result<(), domain::errors::DomainError> { - let user_uuid = author_user_id.as_uuid(); - let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(user_uuid, &data) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; - - let ap_id = url::Url::parse(&format!("{}/thoughts/{}", self.base_url, thought.id)) - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; - - let mut note = serde_json::json!({ - "type": "Note", - "id": ap_id.to_string(), - "attributedTo": local_actor.ap_id.to_string(), - "content": thought.content.as_str(), - "published": thought.created_at.to_rfc3339(), - "to": [crate::urls::AS_PUBLIC], - "cc": [local_actor.followers_url.to_string()], - "sensitive": thought.sensitive, - }); - if let Some(ref cw) = thought.content_warning { - note["summary"] = serde_json::json!(cw); - } - if let Some(ref reply_url) = thought.in_reply_to_url { - note["inReplyTo"] = serde_json::json!(reply_url); - } - - self.broadcast_to_followers(user_uuid, ap_id, note) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) - } - - async fn broadcast_delete( - &self, - author_user_id: &domain::value_objects::UserId, - thought_ap_id: &str, - ) -> Result<(), domain::errors::DomainError> { - let user_uuid = author_user_id.as_uuid(); - let ap_id = url::Url::parse(thought_ap_id) - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; - self.broadcast_delete_to_followers(user_uuid, ap_id) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) - } - - async fn broadcast_update( - &self, - author_user_id: &domain::value_objects::UserId, - thought: &domain::models::thought::Thought, - author_username: &str, - ) -> Result<(), domain::errors::DomainError> { - let user_uuid = author_user_id.as_uuid(); - let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(user_uuid, &data) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; - - let ap_id = format!("{}/thoughts/{}", self.base_url, thought.id); - - let mut note = serde_json::json!({ - "type": "Note", - "id": ap_id, - "attributedTo": local_actor.ap_id.to_string(), - "content": thought.content.as_str(), - "published": thought.created_at.to_rfc3339(), - "to": [crate::urls::AS_PUBLIC], - "cc": [local_actor.followers_url.to_string()], - "sensitive": thought.sensitive, - }); - if let Some(ref cw) = thought.content_warning { - note["summary"] = serde_json::json!(cw); - } - if let Some(ref reply_url) = thought.in_reply_to_url { - note["inReplyTo"] = serde_json::json!(reply_url); - } - - self.broadcast_update_to_followers(user_uuid, note) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) - } - - async fn broadcast_announce( - &self, - booster_user_id: &domain::value_objects::UserId, - object_ap_id: &str, - ) -> Result<(), domain::errors::DomainError> { - let user_uuid = booster_user_id.as_uuid(); - let ap_id = url::Url::parse(object_ap_id) - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; - self.broadcast_announce_to_followers(user_uuid, ap_id) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) - } -} -``` - -- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Commit:** - -```bash -git add crates/adapters/activitypub-base/ -git commit -m "feat(activitypub-base): Announce broadcast + impl OutboundFederationPort for ActivityPubService" -``` - ---- - -### Task 5: Thin worker handlers + factory + main - -**Files:** -- Modify: `crates/worker/Cargo.toml` -- Modify: `crates/worker/src/handlers.rs` -- Create: `crates/worker/src/factory.rs` -- Modify: `crates/worker/src/main.rs` - -- [ ] **Update `crates/worker/Cargo.toml`** — add missing deps: - -```toml -[package] -name = "worker" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "thoughts-worker" -path = "src/main.rs" - -[dependencies] -domain = { workspace = true } -application = { workspace = true } -nats = { workspace = true } -event-payload = { workspace = true } -event-transport = { workspace = true } -activitypub-base = { workspace = true } -activitypub = { workspace = true } -postgres = { workspace = true } -postgres-federation = { workspace = true } -async-nats = { workspace = true } -tokio = { workspace = true, features = ["full"] } -futures = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -dotenvy = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -sqlx = { workspace = true } - -[dev-dependencies] -domain = { workspace = true, features = ["test-helpers"] } -``` - -- [ ] **Rewrite `crates/worker/src/handlers.rs`** — thin delegation wrappers only, all tests removed (they now live in `application`): - -```rust -use std::sync::Arc; -use application::services::{FederationEventService, NotificationEventService}; -use domain::{errors::DomainError, events::DomainEvent}; - -pub struct NotificationHandler { - pub service: Arc, -} - -impl NotificationHandler { - pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - self.service.process(event).await - } -} - -pub struct FederationHandler { - pub service: Arc, -} - -impl FederationHandler { - pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - self.service.process(event).await - } -} -``` - -- [ ] **Create `crates/worker/src/factory.rs`:** - -```rust -use std::sync::Arc; -use sqlx::PgPool; - -use activitypub::ThoughtsObjectHandler; -use activitypub_base::ActivityPubService; -use application::services::{FederationEventService, NotificationEventService}; -use postgres::activitypub::PgActivityPubRepository; -use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; - -use crate::handlers::{FederationHandler, NotificationHandler}; - -pub struct WorkerHandlers { - pub notification: NotificationHandler, - pub federation: FederationHandler, -} - -pub async fn build( - database_url: &str, - base_url: &str, - nats_url: &str, -) -> ( - event_transport::EventConsumerAdapter, - WorkerHandlers, -) { - let pool = PgPool::connect(database_url) - .await - .expect("DB connect failed"); - - // Repos - let thoughts = Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())); - let users = Arc::new(postgres::user::PgUserRepository::new(pool.clone())); - let notifications = Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())); - - // ActivityPub service (for federation fan-out) - let ap_service: Arc = Arc::new( - ActivityPubService::new( - Arc::new(PostgresFederationRepository::new(pool.clone())), - Arc::new(PostgresApUserRepository::new(pool.clone(), base_url.to_string())), - Arc::new(ThoughtsObjectHandler::new( - Arc::new(PgActivityPubRepository::new(pool.clone())), - base_url, - )), - base_url.to_string(), - false, - "thoughts".to_string(), - false, - None, - ) - .await - .expect("ActivityPubService build failed"), - ); - - // Application services - let notification_svc = Arc::new(NotificationEventService { - thoughts: thoughts.clone(), - notifications, - }); - let federation_svc = Arc::new(FederationEventService { - thoughts, - users, - ap: ap_service, - base_url: base_url.to_string(), - }); - - // Thin handlers - let handlers = WorkerHandlers { - notification: NotificationHandler { service: notification_svc }, - federation: FederationHandler { service: federation_svc }, - }; - - // NATS consumer - let nats_client = async_nats::connect(nats_url) - .await - .expect("NATS connect failed"); - let consumer = event_transport::EventConsumerAdapter::new( - nats::NatsMessageSource::new(nats_client), - ); - - (consumer, handlers) -} -``` - -- [ ] **Rewrite `crates/worker/src/main.rs`:** - -```rust -mod factory; -mod handlers; - -use futures::StreamExt; -use domain::ports::EventConsumer; - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); - let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()); - let base_url = std::env::var("BASE_URL").expect("BASE_URL required"); - - tracing::info!("Building worker..."); - let (consumer, handlers) = factory::build(&database_url, &base_url, &nats_url).await; - - tracing::info!("Worker started, consuming events..."); - let mut stream = consumer.consume(); - while let Some(result) = stream.next().await { - match result { - Ok(envelope) => { - let event = &envelope.event; - tracing::debug!(?event, "received event"); - - let n = handlers.notification.handle(event).await; - let f = handlers.federation.handle(event).await; - - if n.is_ok() && f.is_ok() { - (envelope.ack)(); - } else { - if let Err(e) = n { tracing::error!("notification handler: {e}"); } - if let Err(e) = f { tracing::error!("federation handler: {e}"); } - (envelope.nack)(); - } - } - Err(e) => tracing::error!("consumer error: {e}"), - } - } -} -``` - -- [ ] **Run:** `cargo check -p worker` — Expected: no errors. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Run full test suite:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -5 -``` - -Expected: all tests pass including the 12 new application service tests. - -- [ ] **Commit:** - -```bash -git add crates/worker/ -git commit -m "refactor(worker): thin handlers + factory — move all business logic to application services" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `OutboundFederationPort` in domain, 4 methods in domain language (Task 1) -- ✅ `NotificationEventService` in application, business logic out of worker (Task 2) -- ✅ 5 notification tests in application crate (Task 2) -- ✅ `FederationEventService` in application: ThoughtCreated/Deleted/Updated/BoostAdded (Task 3) -- ✅ Remote thought guard: `local == false` → skip broadcast (Task 3) -- ✅ 7 federation event tests including remote thought guard and remote-boost AP ID (Task 3) -- ✅ `to`/`cc` added to `AnnounceActivity` for AP compliance (Task 4) -- ✅ `broadcast_announce_to_followers` respects blocked actors/domains (Task 4) -- ✅ `impl OutboundFederationPort for ActivityPubService` builds Note JSON with `inReplyTo`, `summary`, `sensitive` (Task 4) -- ✅ `worker/src/factory.rs` owns all composition — main.rs stays tiny (Task 5) -- ✅ Worker handlers are one-liner delegations (Task 5) -- ✅ Follow/Accept/Reject/Block remain synchronous in HTTP handlers — unchanged - -**Placeholder scan:** None. - -**Type consistency:** -- `UserId::as_uuid()` used in impl — confirmed available in `value_objects.rs:11` -- `Content::as_str()`, `Username::as_str()` — confirmed available -- `Thought.local: bool` — used for guard in `FederationEventService` -- `Thought.ap_id: Option` — used for boost AP ID construction -- `ActivityPubService::broadcast_to_followers(uuid::Uuid, Url, Value)` — matches existing signature -- `broadcast_update_to_followers(uuid::Uuid, Value)` — matches existing signature -- `ThoughtsObjectHandler::new(Arc, &str)` — matches bootstrap factory usage -- `PostgresApUserRepository::new(PgPool, String)` — matches bootstrap factory usage diff --git a/docs/superpowers/plans/2026-05-14-merge-readiness.md b/docs/superpowers/plans/2026-05-14-merge-readiness.md deleted file mode 100644 index 1d6fdeb..0000000 --- a/docs/superpowers/plans/2026-05-14-merge-readiness.md +++ /dev/null @@ -1,562 +0,0 @@ -# Merge Readiness Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close the remaining gaps between v2 and v1 so the new Rust backend can replace the old one. Five tasks: fix feed response hydration, wire missing follower/following routes, add user listing endpoints, add popular tags, harden config (HOST, CORS, rate limiting). - -**Architecture:** All changes are in `presentation`, `domain/ports`, `adapters/postgres`, and `bootstrap`. No changes to `application` or `worker`. - ---- - -## File Map - -``` -Task 1 — Feed hydration: - Modify: crates/presentation/src/handlers/feed.rs ← add to_thought_response helper, fix 4 handlers - Modify: crates/presentation/src/handlers/auth.rs ← move/export to_feed_entry helper if needed - -Task 2 — Wire follower/following routes: - Modify: crates/presentation/src/routes.rs ← add 2 routes - -Task 3 — User listing + count: - Modify: crates/domain/src/ports.rs ← add count() to UserRepository - Modify: crates/adapters/postgres/src/user.rs ← implement count() - Modify: crates/domain/src/testing.rs ← add count() to TestStore - Modify: crates/presentation/src/handlers/users.rs ← add get_users, get_user_count handlers - Modify: crates/presentation/src/routes.rs ← add 2 routes - -Task 4 — Popular tags: - Modify: crates/domain/src/ports.rs ← add popular_tags() to TagRepository - Modify: crates/adapters/postgres/src/tag.rs ← implement popular_tags() - Modify: crates/domain/src/testing.rs ← add popular_tags() to TestStore - Modify: crates/presentation/src/handlers/feed.rs ← add get_popular_tags handler - Modify: crates/presentation/src/routes.rs ← add 1 route (before /tags/{name}) - -Task 5 — Config: HOST, CORS_ORIGINS, RATE_LIMIT: - Modify: crates/bootstrap/src/config.rs ← 3 new fields - Modify: crates/bootstrap/src/main.rs ← use HOST, CORS layer, rate limit layer - Modify: crates/bootstrap/Cargo.toml ← add tower-governor - Modify: .env.example ← document new vars -``` - ---- - -### Task 1: Fix feed response hydration - -**Files:** -- Modify: `crates/presentation/src/handlers/feed.rs` - -**Problem:** `home_feed` and `public_feed` return only UUIDs. `user_thoughts_handler` and `tag_thoughts_handler` are missing `author`, `in_reply_to_id`, `sensitive`, `content_warning`, viewer flags. All four need to use `ThoughtResponse`. - -The `ThoughtResponse` DTO in `api-types` already has every needed field. `FeedEntry` in domain already carries `like_count`, `boost_count`, `reply_count`, `liked_by_viewer`, `boosted_by_viewer`. The conversion is straightforward. - -- [ ] **Add `to_thought_response` helper** at the top of `feed.rs` (after existing imports). This is a private free function: - -```rust -use api_types::responses::ThoughtResponse; - -fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { - ThoughtResponse { - id: e.thought.id.as_uuid(), - content: e.thought.content.as_str().to_string(), - author: crate::handlers::auth::to_user_response(&e.author), - in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()), - visibility: e.thought.visibility.as_str().to_string(), - content_warning: e.thought.content_warning.clone(), - sensitive: e.thought.sensitive, - like_count: e.like_count, - boost_count: e.boost_count, - reply_count: e.reply_count, - liked_by_viewer: e.liked_by_viewer, - boosted_by_viewer: e.boosted_by_viewer, - created_at: e.thought.created_at, - updated_at: e.thought.updated_at, - } -} -``` - -- [ ] **Fix `home_feed`** — replace the UUID-only mapping: - -```rust -pub async fn home_feed( - State(s): State, - AuthUser(uid): AuthUser, - Query(q): Query, -) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?; - Ok(Json(serde_json::json!({ - "items": result.items.iter().map(to_thought_response).collect::>(), - "total": result.total, - "page": result.page, - "per_page": result.per_page, - }))) -} -``` - -- [ ] **Fix `public_feed`** — same pattern: - -```rust -pub async fn public_feed( - State(s): State, - OptionalAuthUser(viewer): OptionalAuthUser, - Query(q): Query, -) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?; - Ok(Json(serde_json::json!({ - "items": result.items.iter().map(to_thought_response).collect::>(), - "total": result.total, - "page": result.page, - "per_page": result.per_page, - }))) -} -``` - -- [ ] **Fix `user_thoughts_handler`** — replace the partial mapping with `to_thought_response`: - -```rust -pub async fn user_thoughts_handler( - State(s): State, - Path(username): Path, - Query(q): Query, -) -> Result, ApiError> { - let user = get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_user_feed(&*s.thoughts, &user.id, page).await?; - Ok(Json(serde_json::json!({ - "total": result.total, - "page": result.page, - "per_page": result.per_page, - "items": result.items.iter().map(to_thought_response).collect::>(), - }))) -} -``` - -- [ ] **Fix `tag_thoughts_handler`** — same: - -```rust -pub async fn tag_thoughts_handler( - State(s): State, - Path(tag_name): Path, - Query(q): Query, -) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_by_tag(&*s.tags, &tag_name, page).await?; - Ok(Json(serde_json::json!({ - "tag": tag_name, - "total": result.total, - "page": result.page, - "per_page": result.per_page, - "items": result.items.iter().map(to_thought_response).collect::>(), - }))) -} -``` - -NOTE: `get_by_tag` returns `Paginated`, not `Paginated` — it won't have author or counts. Check the use case signature. If it returns `Paginated`, map manually keeping available fields only (id, content, visibility, dates). If it returns `Paginated`, use `to_thought_response`. - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Commit:** - -```bash -git add crates/presentation/src/handlers/feed.rs -git commit -m "fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs" -``` - ---- - -### Task 2: Wire follower/following REST routes - -**Files:** -- Modify: `crates/presentation/src/routes.rs` - -`get_followers_handler` and `get_following_handler` already exist in `feed.rs` (lines 75–80). The AP routes own `/users/{username}/followers` and `/users/{username}/following`. Wire the REST handlers at non-conflicting paths: - -- [ ] **Add two routes to `api_routes`** in `routes.rs`, in the users section (before `/thoughts`): - -```rust -.route("/users/{username}/follower-list", get(feed::get_followers_handler)) -.route("/users/{username}/following-list", get(feed::get_following_handler)) -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Commit:** - -```bash -git add crates/presentation/src/routes.rs -git commit -m "feat(presentation): wire GET /users/{username}/follower-list and /following-list" -``` - ---- - -### Task 3: User listing + count - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/adapters/postgres/src/user.rs` -- Modify: `crates/domain/src/testing.rs` -- Modify: `crates/presentation/src/handlers/users.rs` -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Add `count()` to `UserRepository`** in `crates/domain/src/ports.rs`: - -```rust -async fn count(&self) -> Result; -``` - -- [ ] **Implement `count()` in postgres** — find `impl UserRepository for PgUserRepository` in `crates/adapters/postgres/src/user.rs` and add: - -```rust -async fn count(&self) -> Result { - let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true") - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(row) -} -``` - -- [ ] **Implement `count()` in TestStore** in `crates/domain/src/testing.rs`: - -```rust -async fn count(&self) -> Result { - Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64) -} -``` - -- [ ] **Add handlers to `crates/presentation/src/handlers/users.rs`:** - -```rust -use domain::models::feed::UserSummary; - -#[utoipa::path( - get, path = "/users", - params( - ("q" = Option, Query, description = "Search query"), - PaginationQuery, - ), - responses((status = 200, description = "User list")) -)] -pub async fn get_users( - State(s): State, - Query(params): Query>, -) -> Result, ApiError> { - use domain::models::feed::PageParams; - let page = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1u64); - let per_page = params.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(20u64); - let page_params = PageParams { page, per_page }; - - if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) { - let result = s.search.search_users(q, &page_params).await?; - let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect(); - return Ok(Json(serde_json::json!({ "items": users, "total": result.total, "page": result.page, "per_page": result.per_page }))); - } - - let all = s.users.list_with_stats().await?; - let total = all.len() as i64; - let start = ((page - 1) * per_page) as usize; - let items: Vec<_> = all.into_iter().skip(start).take(per_page as usize) - .map(|u| serde_json::json!({ - "id": u.id.as_uuid(), - "username": u.username, - "display_name": u.display_name, - "avatar_url": u.avatar_url, - "bio": u.bio, - "thought_count": u.thought_count, - "follower_count": u.follower_count, - "following_count": u.following_count, - })) - .collect(); - Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page }))) -} - -#[utoipa::path( - get, path = "/users/count", - responses((status = 200, description = "Local user count")) -)] -pub async fn get_user_count( - State(s): State, -) -> Result, ApiError> { - let count = s.users.count().await?; - Ok(Json(serde_json::json!({ "count": count }))) -} -``` - -Note: `get_users` needs `use api_types::requests::PaginationQuery;` added to imports if not already there. Check the file's existing imports. - -- [ ] **Add routes to `routes.rs`** — add BEFORE `/users/me` (static paths must come before parameterised): - -```rust -.route("/users", get(users::get_users)) -.route("/users/count", get(users::get_user_count)) -``` - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Commit:** - -```bash -git add crates/domain/src/ports.rs \ - crates/adapters/postgres/src/user.rs \ - crates/domain/src/testing.rs \ - crates/presentation/src/handlers/users.rs \ - crates/presentation/src/routes.rs -git commit -m "feat: GET /users (search/list) and GET /users/count" -``` - ---- - -### Task 4: Popular tags - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/adapters/postgres/src/tag.rs` -- Modify: `crates/domain/src/testing.rs` -- Modify: `crates/presentation/src/handlers/feed.rs` -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Add `popular_tags()` to `TagRepository`** in `crates/domain/src/ports.rs`: - -```rust -/// Returns (tag_name, thought_count) pairs, most-used first. -async fn popular_tags(&self, limit: usize) -> Result, DomainError>; -``` - -- [ ] **Implement `popular_tags()` in postgres** — find `impl TagRepository for PgTagRepository` in `crates/adapters/postgres/src/tag.rs` and add: - -```rust -async fn popular_tags(&self, limit: usize) -> Result, DomainError> { - let rows = sqlx::query_as::<_, (String, i64)>( - "SELECT t.name, COUNT(tt.thought_id) AS thought_count - FROM tags t - JOIN thought_tags tt ON t.id = tt.tag_id - GROUP BY t.id, t.name - ORDER BY thought_count DESC - LIMIT $1" - ) - .bind(limit as i64) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(rows) -} -``` - -- [ ] **Implement `popular_tags()` in TestStore** in `crates/domain/src/testing.rs`: - -```rust -async fn popular_tags(&self, _limit: usize) -> Result, DomainError> { - Ok(vec![]) -} -``` - -- [ ] **Add `get_popular_tags` handler** to `crates/presentation/src/handlers/feed.rs`: - -```rust -pub async fn get_popular_tags( - State(s): State, - Query(params): Query>, -) -> Result, ApiError> { - let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20); - let tags = s.tags.popular_tags(limit.min(100)).await?; - Ok(Json(serde_json::json!({ - "tags": tags.iter().map(|(name, count)| serde_json::json!({ - "name": name, - "thought_count": count, - })).collect::>() - }))) -} -``` - -- [ ] **Wire `GET /tags/popular` in `routes.rs`** — add BEFORE `/tags/{name}` (otherwise `popular` is captured as the `{name}` param): - -```rust -.route("/tags/popular", get(feed::get_popular_tags)) -.route("/tags/{name}", get(feed::tag_thoughts_handler)) -``` - -The existing `.route("/tags/{name}", ...)` line can stay — just add the popular route immediately before it. - -- [ ] **Run:** `cargo check --workspace` — Expected: no errors. - -- [ ] **Run unit tests:** `cargo test --workspace --exclude postgres --exclude postgres-federation --exclude postgres-search` — Expected: all pass. - -- [ ] **Commit:** - -```bash -git add crates/domain/src/ports.rs \ - crates/adapters/postgres/src/tag.rs \ - crates/domain/src/testing.rs \ - crates/presentation/src/handlers/feed.rs \ - crates/presentation/src/routes.rs -git commit -m "feat: GET /tags/popular — top tags by usage count" -``` - ---- - -### Task 5: Config — HOST, CORS_ORIGINS, RATE_LIMIT - -**Files:** -- Modify: `crates/bootstrap/src/config.rs` -- Modify: `crates/bootstrap/src/main.rs` -- Modify: `crates/bootstrap/Cargo.toml` -- Modify: `.env.example` - -- [ ] **Add `tower-governor` to `crates/bootstrap/Cargo.toml`:** - -```toml -tower-governor = "0.6" -``` - -- [ ] **Add three fields to `Config` in `crates/bootstrap/src/config.rs`:** - -```rust -pub struct Config { - pub database_url: String, - pub jwt_secret: String, - pub base_url: String, - pub nats_url: Option, - pub port: u16, - pub host: String, - pub allow_registration: bool, - pub debug: bool, - /// Comma-separated allowed origins, or "*" for permissive. Default: "*". - pub cors_origins: String, - /// Max requests per minute per IP. None = disabled. - pub rate_limit: Option, -} -``` - -In `from_env()` add: -```rust -host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), -cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()), -rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()), -``` - -- [ ] **Update `crates/bootstrap/src/main.rs`:** - -```rust -mod config; -mod factory; - -use std::sync::Arc; -use http::HeaderValue; -use tower_http::cors::{AllowOrigin, CorsLayer}; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() { - let cfg = config::Config::from_env(); - - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - - let infra = factory::build(&cfg).await; - - // CORS - let cors = if cfg.cors_origins.trim() == "*" { - CorsLayer::permissive() - } else { - let origins: Vec = cfg.cors_origins - .split(',') - .map(|o| o.trim()) - .filter_map(|o| o.parse().ok()) - .collect(); - CorsLayer::new() - .allow_origin(AllowOrigin::list(origins)) - .allow_methods(tower_http::cors::Any) - .allow_headers(tower_http::cors::Any) - }; - - let app = presentation::routes::router(&infra.fed_config) - .with_state(infra.state) - .layer(cors); - - // Rate limiting (optional) - let app = if let Some(rate_limit) = cfg.rate_limit { - use tower_governor::{GovernorLayer, GovernorConfigBuilder}; - let governor_config = Arc::new( - GovernorConfigBuilder::default() - .per_millisecond(60_000 / rate_limit as u64) - .burst_size(rate_limit) - .use_headers() - .finish() - .expect("valid rate limit config"), - ); - let limiter = governor_config.limiter().clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); - loop { - interval.tick().await; - limiter.retain_recent(); - } - }); - app.layer(GovernorLayer { config: governor_config }) - } else { - app - }; - - let addr = format!("{}:{}", cfg.host, cfg.port); - tracing::info!("Listening on {addr}"); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} -``` - -Note: `tower-governor`'s `GovernorLayer` API may differ slightly — check the actual 0.6.x docs and adjust. The `GovernorConfigBuilder` might use `.per_second()` instead of `.per_millisecond()`. Verify and use whichever method produces the desired requests-per-minute rate. - -Note 2: Axum `Router::layer` returns the same type when adding a standard layer. `GovernorLayer` returns a different type. If the type system complains, wrap the app in `tower::ServiceBuilder` or use `.layer(tower::ServiceBuilder::new().layer(GovernorLayer { ... }).into_inner())`. - -- [ ] **Update `.env.example`** — add the three new vars: - -```env -# Optional -HOST=0.0.0.0 -PORT=3000 -ALLOW_REGISTRATION=true -RUST_ENV=development - -# CORS — comma-separated origins, or * for permissive (default: *) -CORS_ORIGINS=* -# CORS_ORIGINS=https://your-nextjs-app.example.com - -# Rate limiting — max requests per minute per IP (disabled by default) -# RATE_LIMIT=60 -``` - -- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (fix tower-governor API if needed). - -- [ ] **Commit:** - -```bash -git add crates/bootstrap/ .env.example -git commit -m "feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `home_feed` / `public_feed` return full `ThoughtResponse` (Task 1) -- ✅ `user_thoughts_handler` / `tag_thoughts_handler` use `to_thought_response` (Task 1) -- ✅ `GET /users/{username}/follower-list` and `/following-list` wired (Task 2) -- ✅ `GET /users` (search + list) + `GET /users/count` (Task 3) -- ✅ `UserRepository::count()` in port + postgres + TestStore (Task 3) -- ✅ `GET /tags/popular` wired before `/tags/{name}` (Task 4) -- ✅ `TagRepository::popular_tags()` in port + postgres + TestStore (Task 4) -- ✅ `HOST`, `CORS_ORIGINS`, `RATE_LIMIT` in Config (Task 5) -- ✅ CORS layer uses configured origins (Task 5) -- ✅ Rate limiting via tower-governor, disabled by default (Task 5) - -**Placeholder scan:** None. - -**Type consistency:** -- `to_thought_response` maps `FeedEntry` → `ThoughtResponse` — both types confirmed in source -- `tag_thoughts_handler` uses `get_by_tag` which returns `Paginated` — verify whether it returns `Thought` or `FeedEntry` and adjust the mapping accordingly -- `popular_tags()` returns `Vec<(String, i64)>` — matches the SQL query's two columns -- `GovernorLayer` API — implementer must verify against installed tower-governor version diff --git a/docs/superpowers/plans/2026-05-14-openapi-docs.md b/docs/superpowers/plans/2026-05-14-openapi-docs.md deleted file mode 100644 index 3e996d8..0000000 --- a/docs/superpowers/plans/2026-05-14-openapi-docs.md +++ /dev/null @@ -1,822 +0,0 @@ -# OpenAPI / Swagger Docs Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add utoipa OpenAPI documentation to all REST handlers, served at `/docs` (Swagger UI) and `/scalar` — mirroring the movies-diary pattern. - -**Architecture:** `#[utoipa::path]` annotations go on handler functions. `#[derive(utoipa::ToSchema)]` goes on api-types DTOs. Feature-grouped doc structs in `presentation/src/openapi/` assemble the spec. `openapi::serve(router)` merges Swagger UI and Scalar into the axum router. Handlers returning `serde_json::Value` use `inline((status = 200, description = "..."))` or reference inline schema objects. - -**Tech Stack:** utoipa 5.5, utoipa-scalar 0.3, utoipa-swagger-ui 9.0 - ---- - -## File Map - -``` -Modify: crates/presentation/Cargo.toml ← add utoipa, utoipa-scalar, utoipa-swagger-ui -Modify: crates/api-types/Cargo.toml ← add utoipa with uuid feature -Modify: crates/api-types/src/requests.rs ← add #[derive(ToSchema, IntoParams)] -Modify: crates/api-types/src/responses.rs ← add #[derive(ToSchema)] -Create: crates/presentation/src/openapi/mod.rs ← assembles all doc structs, serves /docs + /scalar -Create: crates/presentation/src/openapi/auth.rs -Create: crates/presentation/src/openapi/users.rs -Create: crates/presentation/src/openapi/thoughts.rs -Create: crates/presentation/src/openapi/feed.rs -Create: crates/presentation/src/openapi/social.rs -Create: crates/presentation/src/openapi/notifications.rs -Create: crates/presentation/src/openapi/api_keys.rs -Modify: crates/presentation/src/handlers/auth.rs ← add #[utoipa::path] to 2 handlers -Modify: crates/presentation/src/handlers/users.rs ← add #[utoipa::path] to 3 handlers -Modify: crates/presentation/src/handlers/thoughts.rs ← add #[utoipa::path] to 5 handlers -Modify: crates/presentation/src/handlers/feed.rs ← add #[utoipa::path] to 5 handlers -Modify: crates/presentation/src/handlers/social.rs ← add #[utoipa::path] to 10 handlers -Modify: crates/presentation/src/handlers/notifications.rs ← add #[utoipa::path] to 3 handlers -Modify: crates/presentation/src/handlers/api_keys.rs ← add #[utoipa::path] to 3 handlers -Modify: crates/presentation/src/handlers/health.rs ← add #[utoipa::path] -Modify: crates/presentation/src/handlers/mod.rs ← add pub mod openapi -Modify: crates/presentation/src/routes.rs ← call openapi::serve(router) -Modify: crates/presentation/src/lib.rs ← pub mod openapi -``` - ---- - -### Task 1: Dependencies + ToSchema on api-types - -**Files:** -- Modify: `crates/presentation/Cargo.toml` -- Modify: `crates/api-types/Cargo.toml` -- Modify: `crates/api-types/src/requests.rs` -- Modify: `crates/api-types/src/responses.rs` - -- [ ] **Add deps to `crates/presentation/Cargo.toml`:** - -```toml -utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } -utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } -utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } -``` - -- [ ] **Add dep to `crates/api-types/Cargo.toml`:** - -```toml -utoipa = { version = "5.5.0", features = ["uuid", "chrono"] } -``` - -- [ ] **Add `#[derive(utoipa::ToSchema)]` and `#[derive(utoipa::IntoParams)]` to `crates/api-types/src/requests.rs`:** - -Replace the file with: - -```rust -use serde::Deserialize; -use uuid::Uuid; - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct RegisterRequest { - /// Username (1-32 chars, alphanumeric + underscore) - pub username: String, - pub email: String, - pub password: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct LoginRequest { - pub email: String, - pub password: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct CreateThoughtRequest { - /// Up to 128 characters - pub content: String, - pub in_reply_to_id: Option, - /// One of: "public", "followers", "unlisted", "direct" - pub visibility: Option, - pub content_warning: Option, - pub sensitive: Option, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct EditThoughtRequest { - pub content: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateProfileRequest { - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub header_url: Option, - pub custom_css: Option, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct SetTopFriendsRequest { - /// Ordered list of user UUIDs, max 8 - pub friend_ids: Vec, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct CreateApiKeyRequest { - pub name: String, -} - -#[derive(Deserialize, utoipa::IntoParams)] -pub struct PaginationQuery { - pub page: Option, - pub per_page: Option, -} -impl PaginationQuery { - pub fn page(&self) -> u64 { self.page.unwrap_or(1).max(1) } - pub fn per_page(&self) -> u64 { self.per_page.unwrap_or(20).min(100) } -} - -#[derive(Deserialize, utoipa::IntoParams)] -pub struct SearchQuery { - pub q: String, - pub page: Option, - pub per_page: Option, -} -``` - -- [ ] **Add `#[derive(utoipa::ToSchema)]` to `crates/api-types/src/responses.rs`:** - -Replace the file with: - -```rust -use chrono::{DateTime, Utc}; -use serde::Serialize; -use uuid::Uuid; - -#[derive(Serialize, utoipa::ToSchema)] -pub struct AuthResponse { - pub token: String, - pub user: UserResponse, -} - -#[derive(Serialize, Clone, utoipa::ToSchema)] -pub struct UserResponse { - pub id: Uuid, - pub username: String, - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub header_url: Option, - pub local: bool, - pub created_at: DateTime, -} - -#[derive(Serialize, Clone, utoipa::ToSchema)] -pub struct ThoughtResponse { - pub id: Uuid, - pub content: String, - pub author: UserResponse, - pub in_reply_to_id: Option, - pub visibility: String, - pub content_warning: Option, - pub sensitive: bool, - pub like_count: i64, - pub boost_count: i64, - pub reply_count: i64, - pub liked_by_viewer: bool, - pub boosted_by_viewer: bool, - pub created_at: DateTime, - pub updated_at: Option>, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct PagedResponse { - pub items: Vec, - pub total: i64, - pub page: u64, - pub per_page: u64, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct ApiKeyResponse { - pub id: Uuid, - pub name: String, - pub created_at: DateTime, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct NotificationResponse { - pub id: Uuid, - pub notification_type: String, - pub from_user: Option, - pub thought_id: Option, - pub read: bool, - pub created_at: DateTime, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct ErrorResponse { - pub error: String, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct CreatedApiKeyResponse { - pub id: Uuid, - pub name: String, - /// Raw API key — shown only once at creation - pub key: String, -} -``` - -- [ ] **Run:** `cargo check -p api-types` — Expected: no errors. - -- [ ] **Commit:** - -```bash -git add crates/presentation/Cargo.toml crates/api-types/ -git commit -m "feat(api-types): add utoipa ToSchema and IntoParams derives" -``` - ---- - -### Task 2: Annotate handlers + create openapi modules - -**Files:** All handler files + `crates/presentation/src/openapi/` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/auth.rs`:** - -```rust -#[utoipa::path( - post, path = "/auth/register", - request_body = RegisterRequest, - responses( - (status = 201, description = "User registered", body = AuthResponse), - (status = 409, description = "Username or email taken", body = ErrorResponse), - (status = 422, description = "Invalid input", body = ErrorResponse), - ) -)] -pub async fn post_register(...) { ... } - -#[utoipa::path( - post, path = "/auth/login", - request_body = LoginRequest, - responses( - (status = 200, description = "Login successful", body = AuthResponse), - (status = 401, description = "Invalid credentials", body = ErrorResponse), - ) -)] -pub async fn post_login(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/users.rs`:** - -```rust -#[utoipa::path( - get, path = "/users/me", - responses( - (status = 200, body = UserResponse), - (status = 401, description = "Unauthorized", body = ErrorResponse), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_me(...) { ... } - -#[utoipa::path( - get, path = "/users/{username}", - params(("username" = String, Path, description = "Username")), - responses( - (status = 200, body = UserResponse), - (status = 404, description = "User not found", body = ErrorResponse), - ) -)] -pub async fn get_user(...) { ... } - -#[utoipa::path( - patch, path = "/users/me", - request_body = UpdateProfileRequest, - responses( - (status = 200, body = UserResponse), - (status = 401, description = "Unauthorized", body = ErrorResponse), - ), - security(("bearer_auth" = [])) -)] -pub async fn patch_profile(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/thoughts.rs`:** - -```rust -#[utoipa::path( - post, path = "/thoughts", - request_body = CreateThoughtRequest, - responses( - (status = 201, description = "Thought created"), - (status = 401, description = "Unauthorized", body = ErrorResponse), - (status = 422, description = "Content too long", body = ErrorResponse), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_thought(...) { ... } - -#[utoipa::path( - get, path = "/thoughts/{id}", - params(("id" = Uuid, Path, description = "Thought ID")), - responses( - (status = 200, description = "Thought with author info"), - (status = 404, description = "Not found", body = ErrorResponse), - ) -)] -pub async fn get_thought_handler(...) { ... } - -#[utoipa::path( - patch, path = "/thoughts/{id}", - params(("id" = Uuid, Path, description = "Thought ID")), - request_body = EditThoughtRequest, - responses( - (status = 204, description = "Updated"), - (status = 401, description = "Unauthorized", body = ErrorResponse), - (status = 404, description = "Not found or not owner", body = ErrorResponse), - ), - security(("bearer_auth" = [])) -)] -pub async fn patch_thought(...) { ... } - -#[utoipa::path( - delete, path = "/thoughts/{id}", - params(("id" = Uuid, Path, description = "Thought ID")), - responses( - (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized", body = ErrorResponse), - (status = 404, description = "Not found or not owner", body = ErrorResponse), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_thought_handler(...) { ... } - -#[utoipa::path( - get, path = "/thoughts/{id}/thread", - params(("id" = Uuid, Path, description = "Root thought ID")), - responses( - (status = 200, description = "Thread (root + replies)"), - ) -)] -pub async fn get_thread_handler(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/feed.rs`:** - -```rust -#[utoipa::path( - get, path = "/feed", - params(PaginationQuery), - responses((status = 200, description = "Home feed (followed users' thoughts)")), - security(("bearer_auth" = [])) -)] -pub async fn home_feed(...) { ... } - -#[utoipa::path( - get, path = "/feed/public", - params(PaginationQuery), - responses((status = 200, description = "Public feed (all local thoughts)")) -)] -pub async fn public_feed(...) { ... } - -#[utoipa::path( - get, path = "/search", - params(SearchQuery), - responses((status = 200, description = "Search results: {thoughts, users}")) -)] -pub async fn search_handler(...) { ... } - -#[utoipa::path( - get, path = "/users/{username}/thoughts", - params( - ("username" = String, Path, description = "Username"), - PaginationQuery, - ), - responses((status = 200, description = "User's public thoughts")), -)] -pub async fn user_thoughts_handler(...) { ... } - -#[utoipa::path( - get, path = "/tags/{name}", - params( - ("name" = String, Path, description = "Tag name"), - PaginationQuery, - ), - responses((status = 200, description = "Thoughts with this tag")), -)] -pub async fn tag_thoughts_handler(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/social.rs`:** - -```rust -#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))] -pub async fn post_like(...) { ... } - -#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))] -pub async fn delete_like(...) { ... } - -#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))] -pub async fn post_boost(...) { ... } - -#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))] -pub async fn delete_boost(...) { ... } - -#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))] -pub async fn post_follow(...) { ... } - -#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))] -pub async fn delete_follow(...) { ... } - -#[utoipa::path(post, path = "/users/{id}/block", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] -pub async fn post_block(...) { ... } - -#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] -pub async fn delete_block(...) { ... } - -#[utoipa::path( - put, path = "/users/me/top-friends", - request_body = SetTopFriendsRequest, - responses((status = 204, description = "Top friends updated")), - security(("bearer_auth" = [])) -)] -pub async fn put_top_friends(...) { ... } - -#[utoipa::path( - get, path = "/users/{username}/top-friends", - params(("username" = String, Path, description = "Username")), - responses((status = 200, description = "Top friends list")) -)] -pub async fn get_top_friends_handler(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/notifications.rs`:** - -```rust -#[utoipa::path( - get, path = "/notifications", - responses((status = 200, description = "Notification summary")), - security(("bearer_auth" = [])) -)] -pub async fn list_notifications(...) { ... } - -#[utoipa::path( - post, path = "/notifications/{id}/read", - params(("id" = Uuid, Path, description = "Notification ID")), - responses((status = 204, description = "Marked read")), - security(("bearer_auth" = [])) -)] -pub async fn mark_notification_read(...) { ... } - -#[utoipa::path( - post, path = "/notifications/read-all", - responses((status = 204, description = "All marked read")), - security(("bearer_auth" = [])) -)] -pub async fn mark_all_read(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/api_keys.rs`:** - -```rust -#[utoipa::path( - get, path = "/api-keys", - responses((status = 200, description = "List of API keys", body = Vec)), - security(("bearer_auth" = [])) -)] -pub async fn get_api_keys(...) { ... } - -#[utoipa::path( - post, path = "/api-keys", - request_body = CreateApiKeyRequest, - responses((status = 200, description = "Created API key — raw key shown once", body = CreatedApiKeyResponse)), - security(("bearer_auth" = [])) -)] -pub async fn post_api_key(...) { ... } - -#[utoipa::path( - delete, path = "/api-keys/{id}", - params(("id" = Uuid, Path, description = "API key ID")), - responses((status = 204, description = "Deleted")), - security(("bearer_auth" = [])) -)] -pub async fn delete_api_key_handler(...) { ... } -``` - -- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/health.rs`:** - -```rust -#[utoipa::path( - get, path = "/health", - responses((status = 200, description = "Service health status")) -)] -pub async fn health_handler(...) { ... } -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. Fix any utoipa annotation compile errors (missing imports, wrong types). - -- [ ] **Commit:** - -```bash -git add crates/presentation/src/handlers/ -git commit -m "feat(presentation): add utoipa path annotations to all handlers" -``` - ---- - -### Task 3: OpenAPI doc modules + serve /docs and /scalar - -**Files:** `crates/presentation/src/openapi/` (all new), modify `routes.rs`, `lib.rs` - -- [ ] **Create `crates/presentation/src/openapi/auth.rs`:** - -```rust -use utoipa::OpenApi; -use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, ErrorResponse}}; - -#[derive(OpenApi)] -#[openapi( - paths(crate::handlers::auth::post_register, crate::handlers::auth::post_login), - components(schemas(RegisterRequest, LoginRequest, AuthResponse, ErrorResponse)) -)] -pub struct AuthDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/users.rs`:** - -```rust -use utoipa::OpenApi; -use api_types::{requests::UpdateProfileRequest, responses::{UserResponse, ErrorResponse}}; - -#[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::users::get_me, - crate::handlers::users::get_user, - crate::handlers::users::patch_profile, - ), - components(schemas(UserResponse, UpdateProfileRequest, ErrorResponse)) -)] -pub struct UsersDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/thoughts.rs`:** - -```rust -use utoipa::OpenApi; -use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest}, responses::ErrorResponse}; - -#[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::thoughts::post_thought, - crate::handlers::thoughts::get_thought_handler, - crate::handlers::thoughts::patch_thought, - crate::handlers::thoughts::delete_thought_handler, - crate::handlers::thoughts::get_thread_handler, - ), - components(schemas(CreateThoughtRequest, EditThoughtRequest, ErrorResponse)) -)] -pub struct ThoughtsDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/feed.rs`:** - -```rust -use utoipa::OpenApi; -use api_types::requests::{PaginationQuery, SearchQuery}; - -#[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::feed::home_feed, - crate::handlers::feed::public_feed, - crate::handlers::feed::search_handler, - crate::handlers::feed::user_thoughts_handler, - crate::handlers::feed::tag_thoughts_handler, - ), - components(schemas(PaginationQuery, SearchQuery)) -)] -pub struct FeedDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/social.rs`:** - -```rust -use utoipa::OpenApi; -use api_types::requests::SetTopFriendsRequest; - -#[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::social::post_like, - crate::handlers::social::delete_like, - crate::handlers::social::post_boost, - crate::handlers::social::delete_boost, - crate::handlers::social::post_follow, - crate::handlers::social::delete_follow, - crate::handlers::social::post_block, - crate::handlers::social::delete_block, - crate::handlers::social::put_top_friends, - crate::handlers::social::get_top_friends_handler, - ), - components(schemas(SetTopFriendsRequest)) -)] -pub struct SocialDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/notifications.rs`:** - -```rust -use utoipa::OpenApi; - -#[derive(OpenApi)] -#[openapi(paths( - crate::handlers::notifications::list_notifications, - crate::handlers::notifications::mark_notification_read, - crate::handlers::notifications::mark_all_read, -))] -pub struct NotificationsDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/api_keys.rs`:** - -```rust -use utoipa::OpenApi; -use api_types::{requests::CreateApiKeyRequest, responses::{ApiKeyResponse, CreatedApiKeyResponse}}; - -#[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::api_keys::get_api_keys, - crate::handlers::api_keys::post_api_key, - crate::handlers::api_keys::delete_api_key_handler, - ), - components(schemas(CreateApiKeyRequest, ApiKeyResponse, CreatedApiKeyResponse)) -)] -pub struct ApiKeysDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/health.rs`:** - -```rust -use utoipa::OpenApi; - -#[derive(OpenApi)] -#[openapi(paths(crate::handlers::health::health_handler))] -pub struct HealthDoc; -``` - -- [ ] **Create `crates/presentation/src/openapi/mod.rs`:** - -```rust -mod api_keys; -mod auth; -mod feed; -mod health; -mod notifications; -mod social; -mod thoughts; -mod users; - -use axum::Router; -use utoipa::{ - Modify, OpenApi, - openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme}, -}; -use utoipa_scalar::{Scalar, Servable}; -use utoipa_swagger_ui::SwaggerUi; - -struct SecurityAddon; - -impl Modify for SecurityAddon { - fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - let components = openapi.components.get_or_insert_with(Default::default); - components.add_security_scheme( - "bearer_auth", - SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), - ); - components.add_security_scheme( - "api_key", - SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Api-Key"))), - ); - } -} - -fn build() -> utoipa::openapi::OpenApi { - let mut api = auth::AuthDoc::openapi(); - api.info = utoipa::openapi::InfoBuilder::new() - .title("Thoughts API") - .version("2.0.0") - .description(Some( - "Federated social network API. Authenticate via `POST /auth/login` to get a Bearer token, \ - or use `X-Api-Key` header with a key from `POST /api-keys`." - )) - .build(); - api.merge(users::UsersDoc::openapi()); - api.merge(thoughts::ThoughtsDoc::openapi()); - api.merge(feed::FeedDoc::openapi()); - api.merge(social::SocialDoc::openapi()); - api.merge(notifications::NotificationsDoc::openapi()); - api.merge(api_keys::ApiKeysDoc::openapi()); - api.merge(health::HealthDoc::openapi()); - SecurityAddon.modify(&mut api); - api -} - -pub fn serve(router: Router) -> Router { - tracing::info!("API docs at /docs (Swagger UI) and /scalar (Scalar)"); - let spec = build(); - router - .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) - .merge(Scalar::with_url("/scalar", spec)) -} -``` - -- [ ] **Add `pub mod openapi;`** to `crates/presentation/src/lib.rs`. - -- [ ] **Call `openapi::serve` in `crates/presentation/src/routes.rs`** — update the final return in `router()`: - -```rust -pub fn router(fed_config: &ApFederationConfig) -> Router { - let api_routes = Router::new() - // ... all existing routes unchanged ... - ; - - let ap_routes = Router::new() - // ... all existing AP routes unchanged ... - ; - - let combined = Router::new() - .merge(api_routes) - .merge(ap_routes) - .layer(FederationMiddleware::new(fed_config.0.clone())); - - openapi::serve(combined) -} -``` - -Note: `openapi::serve` takes the combined router and merges the `/docs` and `/scalar` routes. Since it returns `Router` and the swagger/scalar routes don't need state, this works cleanly. - -- [ ] **Run:** `cargo build -p presentation` — Expected: clean build. - -- [ ] **Smoke test:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ -JWT_SECRET=dev BASE_URL=http://localhost:3000 \ -cargo run -p presentation & -sleep 3 - -# Verify OpenAPI JSON is valid -curl -s http://localhost:3000/openapi.json | jq '.info.title' -# Expected: "Thoughts API" - -# Verify docs pages load -curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/docs/ -# Expected: 200 - -curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/scalar -# Expected: 200 - -kill %1 -``` - -- [ ] **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/src/openapi/ \ - crates/presentation/src/lib.rs \ - crates/presentation/src/routes.rs -git commit -m "feat(presentation): OpenAPI docs at /docs (Swagger) and /scalar" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ All REST handlers annotated with `#[utoipa::path]` (Task 2) -- ✅ All request DTOs get `ToSchema` or `IntoParams` (Task 1) -- ✅ All response DTOs get `ToSchema` (Task 1) -- ✅ `CreatedApiKeyResponse` added for the create-key endpoint (Task 1) -- ✅ 8 feature-grouped doc structs assembled in `openapi/mod.rs` (Task 3) -- ✅ Both Bearer token and X-Api-Key security schemes registered (Task 3) -- ✅ `/docs` (Swagger UI) and `/scalar` served (Task 3) -- ✅ `/openapi.json` served (Task 3) - -**Placeholder scan:** None. - -**Type consistency:** -- `CreatedApiKeyResponse` defined in responses.rs (Task 1), referenced in `api_keys.rs` openapi module (Task 3) and annotated in handler (Task 2) -- `PaginationQuery` and `SearchQuery` get `IntoParams` (not `ToSchema`) — correct for query params -- `openapi::serve` takes `Router` generic — works with `Router` from routes.rs - -**Notes:** -- `utoipa-swagger-ui` with `"vendored"` feature bundles the Swagger UI static assets — no CDN dependency -- Handlers returning `serde_json::Value` get response descriptions without body schemas — still useful for documenting status codes and security requirements -- ActivityPub endpoints (inbox, outbox, webfinger, nodeinfo) are intentionally excluded — they serve AP JSON-LD, not REST JSON diff --git a/docs/superpowers/plans/2026-05-14-remote-actor-profile.md b/docs/superpowers/plans/2026-05-14-remote-actor-profile.md deleted file mode 100644 index c55d2d4..0000000 --- a/docs/superpowers/plans/2026-05-14-remote-actor-profile.md +++ /dev/null @@ -1,1288 +0,0 @@ -# Remote Actor Profile Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Display full remote actor profiles at `/users/@user@instance` — avatar, banner, bio, profile fields, and their public posts fetched in the background by the NATS worker. - -**Architecture:** New `DomainEvent::FetchRemoteActorPosts` triggers the worker to fetch a remote outbox page and store notes via `ActivityPubRepository::accept_note`. A new REST endpoint returns cached posts + fires the event. The frontend detects the `@user@domain` URL format and renders a dedicated `RemoteUserProfile` component. - -**Tech Stack:** Rust (axum, domain ports, activitypub_federation, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod, shadcn/ui. - ---- - -## File Map - -| Action | Path | Change | -|--------|------|--------| -| Modify | `crates/domain/src/models/remote_actor.rs` | Add 5 new fields | -| Create | `crates/domain/src/models/remote_note.rs` | New model | -| Modify | `crates/domain/src/models/mod.rs` | `pub mod remote_note` | -| Modify | `crates/domain/src/events.rs` | Add `FetchRemoteActorPosts` variant | -| Modify | `crates/domain/src/ports.rs` | Add `fetch_outbox_page` to `FederationActionPort` | -| Modify | `crates/domain/src/testing.rs` | Stub `fetch_outbox_page` on `TestStore` | -| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `fetch_outbox_page`; populate new `RemoteActor` fields | -| Modify | `crates/adapters/event-payload/src/lib.rs` | Add `FetchRemoteActorPosts` to all 4 impls + test | -| Modify | `crates/presentation/src/state.rs` | Add `ap_repo` field | -| Modify | `crates/bootstrap/src/factory.rs` | Wire `ap_repo` into `AppState` | -| Modify | `crates/api-types/src/responses.rs` | Add `ProfileField`, extend `RemoteActorResponse` | -| Modify | `crates/presentation/src/handlers/feed.rs` | Make `to_thought_response` pub | -| Modify | `crates/presentation/src/handlers/users.rs` | Populate new `RemoteActorResponse` fields in `lookup_handler` | -| Create | `crates/presentation/src/handlers/federation_actors.rs` | `remote_actor_posts_handler` | -| Modify | `crates/presentation/src/handlers/mod.rs` | `pub mod federation_actors` | -| Modify | `crates/presentation/src/routes.rs` | Mount `GET /federation/actors/{handle}/posts` | -| Modify | `crates/application/src/services/federation_event.rs` | Handle `FetchRemoteActorPosts`; add new deps | -| Modify | `crates/worker/src/factory.rs` | Wire `federation_action` + `ap_repo` into `FederationEventService` | -| Modify | `thoughts-frontend/lib/api.ts` | Extend `RemoteActorSchema`; add `getRemoteActorPosts` | -| Create | `thoughts-frontend/components/remote-user-profile.tsx` | Full remote profile component | -| Modify | `thoughts-frontend/app/users/[username]/page.tsx` | Handle detection + remote profile branch | - ---- - -## Task 1: Domain — extend `RemoteActor`, add `RemoteNote`, new event, new port method - -**Files:** -- Modify: `crates/domain/src/models/remote_actor.rs` -- Create: `crates/domain/src/models/remote_note.rs` -- Modify: `crates/domain/src/models/mod.rs` -- Modify: `crates/domain/src/events.rs` -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/testing.rs` - -- [ ] **Step 1: Extend `RemoteActor` with new fields** - -Replace the full content of `crates/domain/src/models/remote_actor.rs`: - -```rust -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone)] -pub struct RemoteActor { - pub url: String, - pub handle: String, - pub display_name: Option, - pub inbox_url: String, - pub shared_inbox_url: Option, - pub public_key: String, - pub avatar_url: Option, - pub last_fetched_at: DateTime, - pub bio: Option, - pub banner_url: Option, - pub also_known_as: Option, - pub outbox_url: Option, - pub attachment: Vec<(String, String)>, -} -``` - -- [ ] **Step 2: Create `RemoteNote`** - -Create `crates/domain/src/models/remote_note.rs`: - -```rust -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone)] -pub struct RemoteNote { - pub ap_id: String, - pub content: String, - pub published: DateTime, - pub sensitive: bool, - pub content_warning: Option, -} -``` - -- [ ] **Step 3: Register in `mod.rs`** - -In `crates/domain/src/models/mod.rs`, add: - -```rust -pub mod remote_note; -``` - -- [ ] **Step 4: Add `FetchRemoteActorPosts` to `DomainEvent`** - -Read `crates/domain/src/events.rs`. Add the new variant at the end of the enum (before the closing brace): - -```rust -FetchRemoteActorPosts { - actor_ap_url: String, - outbox_url: String, -}, -``` - -- [ ] **Step 5: Write failing test** - -In `crates/domain/src/testing.rs`, find the `federation_port_tests` module. Add: - -```rust -#[tokio::test] -async fn test_store_fetch_outbox_returns_empty() { - let store = TestStore::default(); - let notes = store.fetch_outbox_page("https://example.com/outbox", 1).await.unwrap(); - assert!(notes.is_empty()); -} -``` - -- [ ] **Step 6: Run to see compile failure** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests::test_store_fetch_outbox 2>&1 | tail -10 -``` - -Expected: compile error — `fetch_outbox_page` not in trait. - -- [ ] **Step 7: Add `fetch_outbox_page` to `FederationActionPort`** - -Read `crates/domain/src/ports.rs`. In the `FederationActionPort` trait, add after `following_collection_json`: - -```rust -async fn fetch_outbox_page( - &self, - outbox_url: &str, - page: u32, -) -> Result, DomainError>; -``` - -Note: you need to import or reference `RemoteNote`. Since it's in the same crate, use the full path `crate::models::remote_note::RemoteNote` or add it to the use block at the top of the trait impl. Check what's currently imported and add `use crate::models::remote_note::RemoteNote;` to the imports if not present. - -- [ ] **Step 8: Add stub to `TestStore`** - -In `crates/domain/src/testing.rs`, inside `impl FederationActionPort for TestStore`, add: - -```rust -async fn fetch_outbox_page( - &self, - _outbox_url: &str, - _page: u32, -) -> Result, DomainError> { - Ok(vec![]) -} -``` - -- [ ] **Step 9: Fix `RemoteActor` construction sites** - -Adding new fields to `RemoteActor` will break all existing construction sites. Find them: - -```bash -cd /mnt/drive/dev/thoughts && grep -rn "RemoteActor {" --include="*.rs" | grep -v "target/" -``` - -For each construction site (likely in `activitypub-base/src/actors.rs`, `activitypub-base/src/service.rs`, `adapters/postgres/src/remote_actor.rs`), add the new fields with default `None`/`vec![]` values: - -```rust -bio: None, -banner_url: None, -also_known_as: None, -outbox_url: None, -attachment: vec![], -``` - -- [ ] **Step 10: Run tests to confirm pass** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 -``` - -Expected: all tests pass. - -- [ ] **Step 11: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5 -``` - -- [ ] **Step 12: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/domain/src/models/remote_actor.rs \ - crates/domain/src/models/remote_note.rs \ - crates/domain/src/models/mod.rs \ - crates/domain/src/events.rs \ - crates/domain/src/ports.rs \ - crates/domain/src/testing.rs -git commit -m "feat(domain): RemoteActor fields, RemoteNote model, FetchRemoteActorPosts event, fetch_outbox_page port" -``` - ---- - -## Task 2: activitypub-base — implement `fetch_outbox_page` + populate new `RemoteActor` fields - -**Files:** -- Modify: `crates/adapters/activitypub-base/src/service.rs` - -- [ ] **Step 1: Confirm compile failure** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10 -``` - -Expected: error — `fetch_outbox_page` not implemented on `ActivityPubService`. - -- [ ] **Step 2: Update `lookup_actor` to populate new `RemoteActor` fields** - -Read `crates/adapters/activitypub-base/src/service.rs`. Find the `lookup_actor` impl. The current `Ok(domain::models::remote_actor::RemoteActor { ... })` block sets `handle: full_handle` and `avatar_url`. Extend it with the new fields: - -```rust -let domain_str = actor.ap_id.host_str().unwrap_or(""); -let full_handle = format!("{}@{}", actor.username, domain_str); - -Ok(domain::models::remote_actor::RemoteActor { - url: actor.ap_id.to_string(), - handle: full_handle, - display_name: Some(actor.username.clone()), - inbox_url: actor.inbox_url.to_string(), - shared_inbox_url: None, - public_key: actor.public_key_pem.clone(), - avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), - last_fetched_at: actor.last_refreshed_at, - bio: actor.bio.clone(), - banner_url: actor.banner_url.as_ref().map(|u| u.to_string()), - also_known_as: actor.also_known_as.clone(), - outbox_url: Some(actor.outbox_url.to_string()), - attachment: actor - .attachment - .iter() - .map(|f| (f.name.clone(), f.value.clone())) - .collect(), -}) -``` - -- [ ] **Step 3: Implement `fetch_outbox_page`** - -In the `impl domain::ports::FederationActionPort for ActivityPubService` block, after `following_collection_json`, add: - -```rust -async fn fetch_outbox_page( - &self, - outbox_url: &str, - page: u32, -) -> Result, domain::errors::DomainError> { - use chrono::DateTime; - - let url = format!("{}?page={}", outbox_url, page); - let resp: serde_json::Value = reqwest::Client::new() - .get(&url) - .header("Accept", "application/activity+json, application/ld+json") - .send() - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? - .json() - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; - - let empty = vec![]; - let items = resp["orderedItems"].as_array().unwrap_or(&empty); - - let notes = items - .iter() - .filter_map(|item| { - // Items are Create activities wrapping a Note, or Notes directly - let note = if item["type"].as_str() == Some("Create") { - &item["object"] - } else if item["type"].as_str() == Some("Note") { - item - } else { - return None; - }; - - // Only public notes - let to = note["to"].as_array()?; - let is_public = to.iter().any(|t| { - t.as_str() - == Some("https://www.w3.org/ns/activitystreams#Public") - }); - if !is_public { - return None; - } - - let published = DateTime::parse_from_rfc3339( - note["published"].as_str()?, - ) - .ok()? - .with_timezone(&chrono::Utc); - - Some(domain::models::remote_note::RemoteNote { - ap_id: note["id"].as_str()?.to_string(), - content: note["content"].as_str().unwrap_or("").to_string(), - published, - sensitive: note["sensitive"].as_bool().unwrap_or(false), - content_warning: note["summary"] - .as_str() - .map(|s| s.to_string()), - }) - }) - .collect(); - - Ok(notes) -} -``` - -- [ ] **Step 4: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 -``` - -- [ ] **Step 5: Run all tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 6: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/adapters/activitypub-base/src/service.rs -git commit -m "feat(activitypub-base): impl fetch_outbox_page; populate all RemoteActor fields in lookup_actor" -``` - ---- - -## Task 3: event-payload — add `FetchRemoteActorPosts` - -**Files:** -- Modify: `crates/adapters/event-payload/src/lib.rs` - -- [ ] **Step 1: Add variant to `EventPayload` enum** - -Read the file. In the `EventPayload` enum, add at the end (before the closing brace): - -```rust -FetchRemoteActorPosts { - actor_ap_url: String, - outbox_url: String, -}, -``` - -- [ ] **Step 2: Add subject** - -In `impl EventPayload { pub fn subject(&self) -> &'static str { match self { ... } } }`, add: - -```rust -Self::FetchRemoteActorPosts { .. } => "federation.fetch_actor_posts", -``` - -- [ ] **Step 3: Add `From<&DomainEvent>` arm** - -In `impl From<&DomainEvent> for EventPayload { fn from(e: &DomainEvent) -> Self { match e { ... } } }`, add: - -```rust -DomainEvent::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, -} => Self::FetchRemoteActorPosts { - actor_ap_url: actor_ap_url.clone(), - outbox_url: outbox_url.clone(), -}, -``` - -- [ ] **Step 4: Add `TryFrom` arm** - -In `impl TryFrom for DomainEvent { fn try_from(p: EventPayload) -> Result { Ok(match p { ... }) } }`, add: - -```rust -EventPayload::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, -} => DomainEvent::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, -}, -``` - -- [ ] **Step 5: Add to the uniqueness test sample array** - -Find the test that asserts each event has a unique subject (look for a `let samples: Vec = vec![...]` in the `#[cfg(test)]` block). Add to the array: - -```rust -EventPayload::FetchRemoteActorPosts { - actor_ap_url: "https://mastodon.social/users/alice".into(), - outbox_url: "https://mastodon.social/users/alice/outbox".into(), -}, -``` - -- [ ] **Step 6: Compile and test** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -10 -``` - -Expected: all tests pass (uniqueness test passes with the new variant). - -- [ ] **Step 7: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/adapters/event-payload/src/lib.rs -git commit -m "feat(event-payload): add FetchRemoteActorPosts event" -``` - ---- - -## Task 4: AppState + bootstrap — add `ap_repo` - -**Files:** -- Modify: `crates/presentation/src/state.rs` -- Modify: `crates/bootstrap/src/factory.rs` - -- [ ] **Step 1: Add `ap_repo` to `AppState`** - -Read `crates/presentation/src/state.rs`. Add the new field: - -```rust -pub ap_repo: Arc, -``` - -`ActivityPubRepository` is in `domain::ports::*` which is already imported via `use domain::ports::*`. - -- [ ] **Step 2: Wire in `factory.rs`** - -Read `crates/bootstrap/src/factory.rs`. Add the import at the top if not present: - -```rust -use postgres::activitypub::PgActivityPubRepository; -``` - -In the `AppState { ... }` construction block, add: - -```rust -ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), -``` - -- [ ] **Step 3: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p bootstrap 2>&1 | tail -10 -``` - -Expected: no errors. (Presentation tests may fail with missing `ap_repo` in `make_state()` — they will be fixed in Task 5.) - -- [ ] **Step 4: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/presentation/src/state.rs crates/bootstrap/src/factory.rs -git commit -m "feat(bootstrap): add ap_repo to AppState" -``` - ---- - -## Task 5: REST endpoint — extend `RemoteActorResponse`, new handler, update `lookup_handler` - -**Files:** -- Modify: `crates/api-types/src/responses.rs` -- Modify: `crates/presentation/src/handlers/feed.rs` -- Modify: `crates/presentation/src/handlers/users.rs` -- Create: `crates/presentation/src/handlers/federation_actors.rs` -- Modify: `crates/presentation/src/handlers/mod.rs` -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Step 1: Add `ProfileField` + extend `RemoteActorResponse` in api-types** - -Read `crates/api-types/src/responses.rs`. Add a new struct and extend `RemoteActorResponse`: - -```rust -#[derive(Serialize, Clone, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProfileField { - pub name: String, - pub value: String, -} - -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RemoteActorResponse { - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, - pub url: String, - pub bio: Option, - pub banner_url: Option, - pub also_known_as: Option, - pub outbox_url: Option, - pub attachment: Vec, -} -``` - -- [ ] **Step 2: Make `to_thought_response` pub in `feed.rs`** - -Read `crates/presentation/src/handlers/feed.rs`. Find `fn to_thought_response` (currently private) and change it to: - -```rust -pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { -``` - -- [ ] **Step 3: Update `lookup_handler` in `users.rs` to populate new fields** - -Read `crates/presentation/src/handlers/users.rs`. Find `lookup_handler`. Update the `Ok(Json(RemoteActorResponse { ... }))` return to include all new fields: - -```rust -pub async fn lookup_handler( - State(s): State, - Query(q): Query, -) -> Result, ApiError> { - let actor = s.federation.lookup_actor(&q.handle).await?; - Ok(Json(RemoteActorResponse { - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - url: actor.url, - bio: actor.bio, - banner_url: actor.banner_url, - also_known_as: actor.also_known_as, - outbox_url: actor.outbox_url, - attachment: actor - .attachment - .into_iter() - .map(|(name, value)| api_types::responses::ProfileField { name, value }) - .collect(), - })) -} -``` - -- [ ] **Step 4: Write failing tests for the new handler** - -Create `crates/presentation/src/handlers/federation_actors.rs` with tests first: - -```rust -use crate::{ - errors::ApiError, - extractors::OptionalAuthUser, - handlers::feed::to_thought_response, - state::AppState, -}; -use api_types::requests::PaginationQuery; -use application::use_cases::feed::get_user_feed; -use axum::{ - extract::{Path, Query, State}, - Json, -}; -use domain::{events::DomainEvent, models::feed::PageParams}; - -pub async fn remote_actor_posts_handler( - State(_s): State, - Path(_handle): Path, - Query(_q): Query, - OptionalAuthUser(_viewer): OptionalAuthUser, -) -> Result, ApiError> { - todo!() -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::Body, - http::Request, - routing::get, - Router, - }; - use domain::testing::TestStore; - use std::sync::Arc; - use tower::ServiceExt; - - // Copy NoOpAuth and NoOpHasher structs from another handler test module - // (e.g. crates/presentation/src/handlers/notifications.rs tests section). - // They implement AuthService and PasswordHasher minimally for tests. - - fn make_state() -> crate::state::AppState { - let store = Arc::new(TestStore::default()); - crate::state::AppState { - users: store.clone(), - thoughts: store.clone(), - likes: store.clone(), - boosts: store.clone(), - follows: store.clone(), - blocks: store.clone(), - tags: store.clone(), - api_keys: store.clone(), - top_friends: store.clone(), - notifications: store.clone(), - remote_actors: store.clone(), - feed: store.clone(), - search: store.clone(), - auth: Arc::new(NoOpAuth), - hasher: Arc::new(NoOpHasher), - events: store.clone(), - federation: store.clone(), - ap_repo: store.clone(), - } - } - - fn app() -> Router { - Router::new() - .route( - "/federation/actors/{handle}/posts", - get(remote_actor_posts_handler), - ) - .with_state(make_state()) - } - - #[tokio::test] - async fn unknown_actor_returns_404() { - // TestStore.lookup_actor returns NotFound, so unknown handle → 404 - let resp = app() - .oneshot( - Request::builder() - .uri("/federation/actors/%40alice%40example.com/posts") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } -} -``` - -Note: `TestStore` must implement `ActivityPubRepository` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — `TestStore` already implements it (look for `impl ActivityPubRepository for TestStore`). If the `ap_repo` field expects `Arc`, pass `store.clone()`. - -- [ ] **Step 5: Add `pub mod federation_actors` to `mod.rs`** - -In `crates/presentation/src/handlers/mod.rs`, add: - -```rust -pub mod federation_actors; -``` - -- [ ] **Step 6: Run tests to see compile/fail state** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -20 -``` - -Expected: compile error or panic from `todo!()`. - -- [ ] **Step 7: Implement `remote_actor_posts_handler`** - -Replace the `todo!()` body with: - -```rust -pub async fn remote_actor_posts_handler( - State(s): State, - Path(handle): Path, - Query(q): Query, - OptionalAuthUser(viewer): OptionalAuthUser, -) -> Result, ApiError> { - let actor = s.federation.lookup_actor(&handle).await?; - - let ap_url = url::Url::parse(&actor.url) - .map_err(|e| ApiError::BadRequest(e.to_string()))?; - - // Get or create interned local UserId for this remote actor - let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? { - Some(id) => id, - None => s.ap_repo.intern_remote_actor(&ap_url).await?, - }; - - // Return cached posts from DB - let page = PageParams { - page: q.page(), - per_page: q.per_page(), - }; - let result = get_user_feed(&*s.feed, &author_id, &page, viewer.as_ref()).await?; - - // Trigger background outbox fetch (fire and forget — ignore publish errors) - if let Some(outbox_url) = &actor.outbox_url { - let _ = s - .events - .publish(&DomainEvent::FetchRemoteActorPosts { - actor_ap_url: actor.url.clone(), - outbox_url: outbox_url.clone(), - }) - .await; - } - - Ok(Json(serde_json::json!({ - "total": result.total, - "page": result.page, - "per_page": result.per_page, - "items": result.items.iter().map(to_thought_response).collect::>(), - }))) -} -``` - -Add the missing import at the top: - -```rust -use application::use_cases::feed::get_user_feed; -use domain::{events::DomainEvent, models::feed::PageParams}; -use url; -``` - -- [ ] **Step 8: Mount the route** - -Read `crates/presentation/src/routes.rs`. After the `/search` route, add: - -```rust -.route( - "/federation/actors/{handle}/posts", - get(federation_actors::remote_actor_posts_handler), -) -``` - -- [ ] **Step 9: Run tests to confirm pass** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -10 -``` - -Expected: `unknown_actor_returns_404` passes. - -- [ ] **Step 10: Fix any broken tests caused by `ap_repo` in `make_state()`** - -Other test modules (notifications, social, users) also build `AppState` via `make_state()`. They will fail to compile because `AppState` now has `ap_repo`. Find them with: - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "error" | head -20 -``` - -For each test module that constructs `AppState`, add `ap_repo: store.clone()` to the struct literal. - -- [ ] **Step 11: Full compile + test** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 12: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/api-types/src/responses.rs \ - crates/presentation/src/handlers/feed.rs \ - crates/presentation/src/handlers/users.rs \ - crates/presentation/src/handlers/federation_actors.rs \ - crates/presentation/src/handlers/mod.rs \ - crates/presentation/src/routes.rs -git commit -m "feat(presentation): remote actor posts endpoint + extended RemoteActorResponse" -``` - ---- - -## Task 6: Worker — handle `FetchRemoteActorPosts` + wire deps - -**Files:** -- Modify: `crates/application/src/services/federation_event.rs` -- Modify: `crates/worker/src/factory.rs` - -- [ ] **Step 1: Add new deps to `FederationEventService`** - -Read `crates/application/src/services/federation_event.rs`. Add two new fields to the struct: - -```rust -pub struct FederationEventService { - pub thoughts: Arc, - pub users: Arc, - pub ap: Arc, - pub base_url: String, - pub federation_action: Arc, - pub ap_repo: Arc, -} -``` - -- [ ] **Step 2: Handle `FetchRemoteActorPosts` in `process()`** - -In the `match event { ... }` block in `process()`, add a new arm after `DomainEvent::BoostRemoved`: - -```rust -DomainEvent::FetchRemoteActorPosts { - actor_ap_url, - outbox_url, -} => { - let notes = match self - .federation_action - .fetch_outbox_page(outbox_url, 1) - .await - { - Ok(n) => n, - Err(e) => { - tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox"); - return Ok(()); - } - }; - - let actor_url = url::Url::parse(actor_ap_url) - .map_err(|e| DomainError::ExternalService(e.to_string()))?; - - let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?; - - for note in notes { - let ap_id = match url::Url::parse(¬e.ap_id) { - Ok(u) => u, - Err(_) => continue, - }; - // accept_note is idempotent — duplicate ap_ids are ignored - let _ = self - .ap_repo - .accept_note( - &ap_id, - &author_id, - ¬e.content, - note.published, - note.sensitive, - note.content_warning, - "public", - ) - .await; - } - - Ok(()) -} -``` - -Add `url` to the imports at the top of the file if not already imported: - -```rust -use url; -``` - -- [ ] **Step 3: Fix the `FederationEventService` construction in `worker/factory.rs`** - -Read `crates/worker/src/factory.rs`. Currently it creates `ap_service` as `Arc`. Change to create it as a concrete `Arc` first, then cast: - -```rust -use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort}; -``` - -Replace the current `let ap_service: Arc = Arc::new(ActivityPubService::new(...).await.expect("..."))` with: - -```rust -let ap_service = Arc::new( - ActivityPubService::new( - Arc::new(PostgresFederationRepository::new(pool.clone())), - Arc::new(PostgresApUserRepository::new( - pool.clone(), - base_url.to_string(), - )), - Arc::new(ThoughtsObjectHandler::new( - Arc::new(PgActivityPubRepository::new(pool.clone())), - base_url, - )), - base_url.to_string(), - false, - "thoughts".to_string(), - false, - None, - ) - .await - .expect("ActivityPubService build failed"), -); -let ap_outbound = ap_service.clone() as Arc; -let ap_federation = ap_service.clone() as Arc; -let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc; -``` - -Update the `FederationEventService` construction: - -```rust -let federation_svc = Arc::new(FederationEventService { - thoughts, - users, - ap: ap_outbound, - base_url: base_url.to_string(), - federation_action: ap_federation, - ap_repo: ap_repo_worker, -}); -``` - -- [ ] **Step 4: Fix existing tests in `federation_event.rs`** - -The `svc()` helper in tests constructs `FederationEventService` and will now fail because of missing new fields. Find the helper and add: - -```rust -fn svc(store: &TestStore, spy: Arc) -> FederationEventService { - FederationEventService { - thoughts: Arc::new(store.clone()), - users: Arc::new(store.clone()), - ap: spy, - base_url: "https://example.com".to_string(), - federation_action: Arc::new(store.clone()), // TestStore implements FederationActionPort - ap_repo: Arc::new(store.clone()), // TestStore implements ActivityPubRepository - } -} -``` - -- [ ] **Step 5: Write a test for `FetchRemoteActorPosts`** - -In the `#[cfg(test)]` block of `federation_event.rs`, add after the existing tests: - -```rust -#[tokio::test] -async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() { - // TestStore.fetch_outbox_page returns Ok(vec![]) — no notes to store - let store = TestStore::default(); - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::FetchRemoteActorPosts { - actor_ap_url: "https://mastodon.social/users/alice".into(), - outbox_url: "https://mastodon.social/users/alice/outbox".into(), - }) - .await - .unwrap(); - // No assertions needed — just confirm it doesn't panic or error -} -``` - -- [ ] **Step 6: Run tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -15 -``` - -Expected: all existing federation_event tests pass + new test passes. - -- [ ] **Step 7: Full compile + test suite** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 8: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/application/src/services/federation_event.rs \ - crates/worker/src/factory.rs -git commit -m "feat(worker): handle FetchRemoteActorPosts — fetch and store remote outbox notes" -``` - ---- - -## Task 7: Frontend — API + `RemoteUserProfile` component + page routing - -**Files:** -- Modify: `thoughts-frontend/lib/api.ts` -- Create: `thoughts-frontend/components/remote-user-profile.tsx` -- Modify: `thoughts-frontend/app/users/[username]/page.tsx` - -- [ ] **Step 1: Extend `RemoteActorSchema` and add `getRemoteActorPosts` in `api.ts`** - -Read `thoughts-frontend/lib/api.ts`. Replace `RemoteActorSchema` with the enriched version: - -```typescript -export const ProfileFieldSchema = z.object({ - name: z.string(), - value: z.string(), -}); -export type ProfileField = z.infer; - -export const RemoteActorSchema = z.object({ - handle: z.string(), - displayName: z.string().nullable(), - avatarUrl: z.string().nullable(), - url: z.string(), - bio: z.string().nullable(), - bannerUrl: z.string().nullable(), - alsoKnownAs: z.string().nullable(), - outboxUrl: z.string().nullable(), - attachment: z.array(ProfileFieldSchema), -}); -export type RemoteActor = z.infer; -``` - -After `lookupRemoteActor`, add: - -```typescript -export const getRemoteActorPosts = ( - handle: string, - page: number, - token: string | null -) => - apiFetch( - `/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`, - {}, - z.object({ - total: z.number(), - page: z.number(), - per_page: z.number(), - items: z.array(ThoughtSchema), - }), - token - ); -``` - -- [ ] **Step 2: Create `RemoteUserProfile` component** - -Create `thoughts-frontend/components/remote-user-profile.tsx`: - -```typescript -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { UserAvatar } from "@/components/user-avatar"; -import { ThoughtList } from "@/components/thought-list"; -import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { ExternalLink, UserPlus, UserMinus } from "lucide-react"; -import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api"; -import { toast } from "sonner"; -import { useAuth } from "@/hooks/use-auth"; - -interface RemoteUserProfileProps { - actor: RemoteActor; - initialPosts: Thought[]; - me: Me | null; -} - -export function RemoteUserProfile({ - actor, - initialPosts, - me, -}: RemoteUserProfileProps) { - const [followed, setFollowed] = useState(false); - const [loading, setLoading] = useState(false); - const { token } = useAuth(); - - const handleFollow = async () => { - if (!token) { - toast.error("You must be logged in to follow users."); - return; - } - setLoading(true); - try { - if (followed) { - await unfollowUser(actor.handle, token); - setFollowed(false); - } else { - await followUser(actor.handle, token); - setFollowed(true); - toast.success(`Follow request sent to ${actor.handle}`); - } - } catch { - toast.error(followed ? "Failed to unfollow." : "Failed to send follow request."); - } finally { - setLoading(false); - } - }; - - const isOwnProfile = me?.username === actor.handle; - - // Build authorDetails for ThoughtList - const authorDetails = new Map(); - initialPosts.forEach((t) => { - authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl }); - }); - - return ( -
- {/* Banner */} -
- -
- {/* Left sidebar */} - - - {/* Posts */} -
- {initialPosts.length > 0 ? ( - - ) : ( - -

- Posts are being fetched — check back soon. -

-
- )} -
-
-
- ); -} -``` - -Note: `dangerouslySetInnerHTML` on `field.value` is needed because Mastodon returns HTML in profile field values (e.g. links). This is safe because the data comes from a trusted AP fetch, not user input. - -- [ ] **Step 3: Update `app/users/[username]/page.tsx` to handle remote actors** - -Read the full file. Add a handle-detection branch at the top of `ProfilePage`, before the existing promise setup: - -```typescript -import { - getFollowersList, - getFollowingList, - getMe, - getTopFriends, - getUserProfile, - getUserThoughts, - lookupRemoteActor, - getRemoteActorPosts, - Me, -} from "@/lib/api"; -import { RemoteUserProfile } from "@/components/remote-user-profile"; -// ... existing imports unchanged -``` - -After `const { username } = await params;` and `const token = ...`, add the branch: - -```typescript -const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/; - -if (HANDLE_RE.test(username)) { - const [actorResult, postsResult, meResult] = await Promise.allSettled([ - lookupRemoteActor(username, token), - getRemoteActorPosts(username, 1, token), - token ? getMe(token) : Promise.resolve(null), - ]); - - if (actorResult.status === "rejected") { - notFound(); - } - - const actor = actorResult.value; - const posts = - postsResult.status === "fulfilled" ? postsResult.value.items : []; - const me = - meResult.status === "fulfilled" ? (meResult.value as Me | null) : null; - - return ; -} -``` - -Place this block immediately before the existing `const userProfilePromise = ...` line. The rest of the file continues unchanged. - -- [ ] **Step 4: Type-check** - -```bash -cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 -``` - -Expected: no errors. If `ThoughtList` props don't match, check its interface and adjust. - -- [ ] **Step 5: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add thoughts-frontend/lib/api.ts \ - thoughts-frontend/components/remote-user-profile.tsx \ - thoughts-frontend/app/users/[username]/page.tsx -git commit -m "feat(frontend): remote actor profile page with bio, fields, and posts" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `RemoteActor` extended with bio, banner_url, also_known_as, outbox_url, attachment — Task 1 + 2 -- ✅ `RemoteNote` domain model — Task 1 -- ✅ `FetchRemoteActorPosts` domain event — Task 1 -- ✅ `fetch_outbox_page` port method — Task 1 + 2 -- ✅ `fetch_outbox_page` impl (HTTPS, Create/Note both handled, public-only filter) — Task 2 -- ✅ `lookup_actor` populates new fields — Task 2 -- ✅ `EventPayload::FetchRemoteActorPosts` (enum, subject, From, TryFrom, test) — Task 3 -- ✅ `AppState.ap_repo` wired — Task 4 -- ✅ `ProfileField` + extended `RemoteActorResponse` — Task 5 -- ✅ `to_thought_response` made pub — Task 5 -- ✅ `lookup_handler` updated to return new fields — Task 5 -- ✅ `GET /federation/actors/{handle}/posts` endpoint — Task 5 -- ✅ Worker handles `FetchRemoteActorPosts` — Task 6 -- ✅ Worker factory wires new deps — Task 6 -- ✅ `RemoteActorSchema` extended + `getRemoteActorPosts` — Task 7 -- ✅ `RemoteUserProfile` component (banner, avatar, bio, fields, alsoKnownAs, external link, follow, posts) — Task 7 -- ✅ Handle detection in profile page — Task 7 - -**Placeholder scan:** None found. - -**Type consistency:** -- `RemoteNote { ap_id, content, published, sensitive, content_warning }` defined Task 1, used in Task 2 impl and Task 6 worker ✅ -- `actor.outbox_url: Option` returned by `lookup_actor` (Task 2), used in handler (Task 5) and event payload (Task 3) ✅ -- `RemoteActorResponse.attachment: Vec` defined Task 5, mapped from `actor.attachment: Vec<(String, String)>` in Task 2 ✅ -- `FederationEventService { federation_action, ap_repo }` — new fields added Task 6 step 1, wired in factory Task 6 step 3, test helper updated Task 6 step 4 ✅ -- `ap_repo: Arc` in `AppState` added Task 4, used in Task 5 handler, used in test `make_state()` Task 5 step 4 ✅ -- `getRemoteActorPosts` returns `{ items: ThoughtSchema[] }` — `ThoughtSchema` already imported in `api.ts` ✅ diff --git a/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md b/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md deleted file mode 100644 index 5a4858f..0000000 --- a/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md +++ /dev/null @@ -1,917 +0,0 @@ -# Remote Actor Search & Follow Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let local users search for and follow ActivityPub users on other instances (e.g. `@user@mastodon.social`) from the existing search page. - -**Architecture:** New `FederationActionPort` domain trait (lookup + follow), implemented by `ActivityPubService` in `activitypub-base`. Injected into `AppState` via bootstrap. Two new REST endpoints at `/federation/lookup` and `/federation/follow`. Frontend detects `@user@instance` handle format in the search bar and renders a `RemoteUserCard` with a Follow button. - -**Tech Stack:** Rust (axum, sqlx, activitypub_federation crate), Next.js 15 (App Router, server components), TypeScript, Zod, shadcn/ui. - ---- - -## File Map - -| Action | Path | Purpose | -|--------|------|---------| -| Modify | `crates/domain/src/models/remote_actor.rs` | Add `avatar_url` field | -| Modify | `crates/domain/src/errors.rs` | Add `ExternalService` variant | -| Modify | `crates/domain/src/ports.rs` | Add `FederationActionPort` trait | -| Modify | `crates/domain/src/testing.rs` | Impl `FederationActionPort` for `TestStore` | -| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `FederationActionPort` for `ActivityPubService` | -| Modify | `crates/adapters/activitypub-base/src/lib.rs` | Re-export trait impl visibility | -| Modify | `crates/presentation/src/state.rs` | Add `federation` field | -| Modify | `crates/presentation/src/errors.rs` | Map `ExternalService` → 502 | -| Modify | `crates/bootstrap/src/factory.rs` | Build `ActivityPubService`, wire `federation` | -| Modify | `crates/bootstrap/src/main.rs` | Use `ap_service.federation_config()` for middleware | -| Modify | `crates/api-types/src/responses.rs` | Add `RemoteActorResponse` | -| Create | `crates/presentation/src/handlers/federation.rs` | `lookup` + `follow_remote` handlers | -| Modify | `crates/presentation/src/handlers/mod.rs` | Expose `federation` module | -| Modify | `crates/presentation/src/routes.rs` | Mount `/federation/*` routes | -| Modify | `thoughts-frontend/lib/api.ts` | Add schema, `lookupRemoteActor`, `followRemoteUser` | -| Modify | `thoughts-frontend/app/search/page.tsx` | Detect handle, call lookup, pass result | -| Create | `thoughts-frontend/components/remote-user-card.tsx` | Shows remote actor + Follow button | - ---- - -## Task 1: Domain model + port - -**Files:** -- Modify: `crates/domain/src/models/remote_actor.rs` -- Modify: `crates/domain/src/errors.rs` -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/testing.rs` - -- [ ] **Step 1: Add `avatar_url` to `RemoteActor`** - -In `crates/domain/src/models/remote_actor.rs`, add one field: - -```rust -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone)] -pub struct RemoteActor { - pub url: String, - pub handle: String, - pub display_name: Option, - pub inbox_url: String, - pub shared_inbox_url: Option, - pub public_key: String, - pub avatar_url: Option, // ← add this - pub last_fetched_at: DateTime, -} -``` - -- [ ] **Step 2: Add `ExternalService` to `DomainError`** - -In `crates/domain/src/errors.rs`, add the variant: - -```rust -#[derive(Debug, Error, Clone)] -pub enum DomainError { - #[error("not found")] - NotFound, - #[error("unauthorized")] - Unauthorized, - #[error("forbidden")] - Forbidden, - #[error("conflict: {0}")] - Conflict(String), - #[error("invalid input: {0}")] - InvalidInput(String), - #[error("external service error: {0}")] - ExternalService(String), // ← add this - #[error("internal error: {0}")] - Internal(String), -} -``` - -- [ ] **Step 3: Add `FederationActionPort` trait** - -In `crates/domain/src/ports.rs`, after the `RemoteActorRepository` trait block, add: - -```rust -#[async_trait] -pub trait FederationActionPort: Send + Sync { - async fn lookup_actor(&self, handle: &str) -> Result; - async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; -} -``` - -Make sure `RemoteActor` is already imported — it's in the existing `use crate::models::remote_actor::RemoteActor;` import block. - -- [ ] **Step 4: Write failing tests for the trait in `testing.rs`** - -At the bottom of `crates/domain/src/testing.rs`, add: - -```rust -#[cfg(test)] -mod federation_port_tests { - use super::*; - use crate::value_objects::UserId; - - fn uid() -> UserId { - UserId::new() - } - - #[tokio::test] - async fn test_store_lookup_returns_not_found() { - let store = TestStore::default(); - let err = store.lookup_actor("@alice@example.com").await.unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[tokio::test] - async fn test_store_follow_remote_is_noop_ok() { - let store = TestStore::default(); - store.follow_remote(&uid(), "@alice@example.com").await.unwrap(); - } -} -``` - -- [ ] **Step 5: Run the tests to see them fail** - -```bash -cargo test -p domain -- federation_port_tests 2>&1 | tail -20 -``` - -Expected: compile error — `lookup_actor` and `follow_remote` not implemented on `TestStore`, and `FederationActionPort` trait not found. - -- [ ] **Step 6: Implement `FederationActionPort` for `TestStore`** - -In `crates/domain/src/testing.rs`, add after the existing `impl RemoteActorRepository for TestStore` block: - -```rust -#[async_trait] -impl FederationActionPort for TestStore { - async fn lookup_actor(&self, _handle: &str) -> Result { - Err(DomainError::NotFound) - } - - async fn follow_remote(&self, _local_user_id: &UserId, _handle: &str) -> Result<(), DomainError> { - Ok(()) - } -} -``` - -- [ ] **Step 7: Run tests to confirm they pass** - -```bash -cargo test -p domain -- federation_port_tests 2>&1 | tail -10 -``` - -Expected: `test federation_port_tests::test_store_lookup_returns_not_found ... ok` and `test_store_follow_remote_is_noop_ok ... ok`. - -- [ ] **Step 8: Confirm the whole domain crate still compiles** - -```bash -cargo check -p domain 2>&1 | tail -10 -``` - -Expected: no errors. - -- [ ] **Step 9: Commit** - -```bash -git add crates/domain/src/models/remote_actor.rs \ - crates/domain/src/errors.rs \ - crates/domain/src/ports.rs \ - crates/domain/src/testing.rs -git commit -m "feat(domain): FederationActionPort trait + avatar_url on RemoteActor" -``` - ---- - -## Task 2: `activitypub-base` — implement `FederationActionPort` - -**Files:** -- Modify: `crates/adapters/activitypub-base/src/service.rs` - -- [ ] **Step 1: Write a compile-time impl check in `tests/service.rs`** - -In `crates/adapters/activitypub-base/src/tests/service.rs`, add at the top: - -```rust -// Verify ActivityPubService satisfies the FederationActionPort contract at compile time. -fn _assert_impl_federation_action_port() -where - crate::service::ActivityPubService: domain::ports::FederationActionPort, -{ -} -``` - -- [ ] **Step 2: Run to see compile failure** - -```bash -cargo check -p activitypub-base 2>&1 | tail -15 -``` - -Expected: error — `ActivityPubService` does not implement `FederationActionPort`. - -- [ ] **Step 3: Implement `FederationActionPort` for `ActivityPubService`** - -At the bottom of `crates/adapters/activitypub-base/src/service.rs`, before the closing of the file, add: - -```rust -#[async_trait::async_trait] -impl domain::ports::FederationActionPort for ActivityPubService { - async fn lookup_actor( - &self, - handle: &str, - ) -> Result { - use activitypub_federation::fetch::webfinger::webfinger_resolve_actor; - let data = self.federation_config.to_request_data(); - let actor: crate::actors::DbActor = webfinger_resolve_actor(handle, &data) - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; - Ok(domain::models::remote_actor::RemoteActor { - url: actor.ap_id.to_string(), - handle: actor.username.clone(), - display_name: actor.bio.clone(), - inbox_url: actor.inbox_url.to_string(), - shared_inbox_url: None, - public_key: actor.public_key_pem.clone(), - avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), - last_fetched_at: actor.last_refreshed_at, - }) - } - - async fn follow_remote( - &self, - local_user_id: &domain::value_objects::UserId, - handle: &str, - ) -> Result<(), domain::errors::DomainError> { - self.follow(local_user_id.inner(), handle) - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) - } -} -``` - -Note: `UserId::inner()` returns the underlying `uuid::Uuid`. Verify the method name with `grep -n "fn inner\|fn as_uuid\|fn into_uuid" crates/domain/src/value_objects.rs` — adjust if the method is named differently. - -- [ ] **Step 4: Check `UserId` accessor method name** - -```bash -grep -n "fn inner\|fn as_uuid\|fn into_uuid\|pub fn " /mnt/drive/dev/thoughts/crates/domain/src/value_objects.rs | grep -i "userid\|UserId" | head -10 -``` - -If `inner()` doesn't exist, replace `local_user_id.inner()` with the correct method (e.g. `local_user_id.0`, `local_user_id.as_uuid()`, etc.). - -- [ ] **Step 5: Compile to confirm the impl satisfies the trait** - -```bash -cargo check -p activitypub-base 2>&1 | tail -10 -``` - -Expected: no errors. - -- [ ] **Step 6: Commit** - -```bash -git add crates/adapters/activitypub-base/src/service.rs \ - crates/adapters/activitypub-base/src/tests/service.rs -git commit -m "feat(activitypub-base): impl FederationActionPort for ActivityPubService" -``` - ---- - -## Task 3: Bootstrap — wire `ActivityPubService` into `AppState` - -**Files:** -- Modify: `crates/presentation/src/state.rs` -- Modify: `crates/presentation/src/errors.rs` -- Modify: `crates/bootstrap/src/factory.rs` -- Modify: `crates/bootstrap/src/main.rs` - -- [ ] **Step 1: Add `federation` to `AppState`** - -In `crates/presentation/src/state.rs`, add the new field: - -```rust -use domain::ports::*; -use std::sync::Arc; - -#[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 federation: Arc, // ← add this -} -``` - -- [ ] **Step 2: Map `ExternalService` error in `presentation/src/errors.rs`** - -Add the new match arm in `IntoResponse for ApiError`: - -```rust -Self::Domain(DomainError::ExternalService(_)) => ( - StatusCode::BAD_GATEWAY, - "external service error".into(), -), -``` - -Place it before the `Self::Domain(DomainError::Internal(_))` arm. - -- [ ] **Step 3: Refactor `factory.rs` to build `ActivityPubService`** - -In `crates/bootstrap/src/factory.rs`, change the imports and the federation setup block. - -Add import at top: -```rust -use activitypub_base::service::ActivityPubService; -use domain::ports::FederationActionPort; -``` - -Change `Infrastructure` struct: -```rust -pub struct Infrastructure { - pub state: AppState, - pub ap_service: Arc, -} -``` - -Replace the current "3. ActivityPub federation" block (which builds `fed_data` + `fed_config`) with: - -```rust -// 3. ActivityPub federation -let ap_service = Arc::new( - ActivityPubService::new( - Arc::new(PostgresFederationRepository::new(pool.clone())), - Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), - Arc::new(ThoughtsObjectHandler::new( - Arc::new(PgActivityPubRepository::new(pool.clone())), - &cfg.base_url, - )), - cfg.base_url.clone(), - cfg.allow_registration, - "thoughts".to_string(), - cfg.debug, - None, - ) - .await - .expect("Failed to build ActivityPubService"), -); -``` - -Remove the old `let fed_config = ...` line entirely. - -In the `AppState { ... }` construction, add: -```rust -federation: ap_service.clone() as Arc, -``` - -Change the `Infrastructure { ... }` return to: -```rust -Infrastructure { state, ap_service } -``` - -- [ ] **Step 4: Update `main.rs` to use `ap_service`** - -In `crates/bootstrap/src/main.rs`, change the middleware line from: - -```rust -.layer(infra.fed_config.middleware()); -``` - -to: - -```rust -.layer(infra.ap_service.federation_config().middleware()); -``` - -Also update the AP router handlers — they use `actor_handler`, `inbox_handler`, etc. from `activitypub_base`. These don't change; only the middleware source changes. - -- [ ] **Step 5: Confirm everything compiles** - -```bash -cargo check -p bootstrap 2>&1 | tail -15 -``` - -Expected: no errors. If `fed_config` is referenced elsewhere in `main.rs` or `factory.rs`, fix those references to use `ap_service.federation_config()`. - -- [ ] **Step 6: Commit** - -```bash -git add crates/presentation/src/state.rs \ - crates/presentation/src/errors.rs \ - crates/bootstrap/src/factory.rs \ - crates/bootstrap/src/main.rs -git commit -m "feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState" -``` - ---- - -## Task 4: REST endpoints — lookup + follow - -**Files:** -- Modify: `crates/api-types/src/responses.rs` -- Create: `crates/presentation/src/handlers/federation.rs` -- Modify: `crates/presentation/src/handlers/mod.rs` -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Step 1: Add `RemoteActorResponse` to `api-types`** - -In `crates/api-types/src/responses.rs`, add: - -```rust -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RemoteActorResponse { - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, - pub url: String, -} -``` - -- [ ] **Step 2: Write failing handler tests** - -Create `crates/presentation/src/handlers/federation.rs` with the test module first: - -```rust -use axum::{ - extract::{Query, State}, - http::StatusCode, - Json, -}; -use serde::Deserialize; - -use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse}; -use domain::errors::DomainError; - -use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; - -pub async fn lookup_handler( - State(_s): State, - Query(_q): Query, -) -> Result, ApiError> { - todo!() -} - -pub async fn follow_remote_handler( - State(_s): State, - AuthUser(_uid): AuthUser, - Json(_body): Json, -) -> Result { - todo!() -} - -#[derive(Deserialize)] -pub struct LookupQuery { - pub handle: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::Body, - http::{Request, header}, - routing::{get, post}, - Router, - }; - use domain::testing::TestStore; - use std::sync::Arc; - use tower::ServiceExt; - - fn make_state() -> AppState { - let store = Arc::new(TestStore::default()); - AppState { - users: store.clone(), - thoughts: store.clone(), - likes: store.clone(), - boosts: store.clone(), - follows: store.clone(), - blocks: store.clone(), - tags: store.clone(), - api_keys: store.clone(), - top_friends: store.clone(), - notifications: store.clone(), - remote_actors: store.clone(), - feed: store.clone(), - search: store.clone(), - auth: store.clone(), - hasher: store.clone(), - events: store.clone(), - federation: store.clone(), - } - } - - fn app() -> Router { - Router::new() - .route("/federation/lookup", get(lookup_handler)) - .route("/federation/follow", post(follow_remote_handler)) - .with_state(make_state()) - } - - #[tokio::test] - async fn lookup_unknown_handle_returns_404() { - let resp = app() - .oneshot( - Request::builder() - .uri("/federation/lookup?handle=%40alice%40example.com") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn follow_remote_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("POST") - .uri("/federation/follow") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"handle":"@alice@example.com"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } -} -``` - -Note: `TestStore` must implement `AuthService`, `PasswordHasher`, and `FederationActionPort` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — if `TestStore` doesn't implement `AuthService` or `PasswordHasher`, use the existing pattern from other handler test setups in the codebase. You may need to construct `AppState` slightly differently (e.g. using a `NoOpAuth` stub). Check `crates/presentation/src/handlers/auth.rs` for any existing test patterns. - -- [ ] **Step 3: Add `FollowRemoteRequest` to `api-types`** - -In `crates/api-types/src/requests.rs`, add: - -```rust -#[derive(serde::Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct FollowRemoteRequest { - pub handle: String, -} -``` - -- [ ] **Step 4: Run tests to see them fail** - -```bash -cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -20 -``` - -Expected: compile errors (handler bodies are `todo!()`) or panics. The goal is to confirm the tests exist and the wiring is right. - -- [ ] **Step 5: Implement the handlers** - -Replace the `todo!()` bodies in `federation.rs`: - -```rust -pub async fn lookup_handler( - State(s): State, - Query(q): Query, -) -> Result, ApiError> { - let actor = s.federation.lookup_actor(&q.handle).await?; - Ok(Json(RemoteActorResponse { - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - url: actor.url, - })) -} - -pub async fn follow_remote_handler( - State(s): State, - AuthUser(uid): AuthUser, - Json(body): Json, -) -> Result { - s.federation.follow_remote(&uid, &body.handle).await?; - Ok(StatusCode::NO_CONTENT) -} -``` - -- [ ] **Step 6: Expose the module** - -In `crates/presentation/src/handlers/mod.rs`, add: - -```rust -pub mod federation; -``` - -- [ ] **Step 7: Mount routes** - -In `crates/presentation/src/routes.rs`, add these two routes inside `let api_routes = Router::new()`: - -```rust -.route("/federation/lookup", get(federation::lookup_handler)) -.route("/federation/follow", post(federation::follow_remote_handler)) -``` - -Place them after the `/search` route for clarity. - -- [ ] **Step 8: Run tests again to confirm they pass** - -```bash -cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -15 -``` - -Expected: -``` -test handlers::federation::tests::lookup_unknown_handle_returns_404 ... ok -test handlers::federation::tests::follow_remote_without_auth_returns_401 ... ok -``` - -- [ ] **Step 9: Full compile check** - -```bash -cargo check 2>&1 | tail -15 -``` - -Expected: no errors. - -- [ ] **Step 10: Commit** - -```bash -git add crates/api-types/src/responses.rs \ - crates/api-types/src/requests.rs \ - crates/presentation/src/handlers/federation.rs \ - crates/presentation/src/handlers/mod.rs \ - crates/presentation/src/routes.rs -git commit -m "feat(presentation): /federation/lookup and /federation/follow endpoints" -``` - ---- - -## Task 5: Frontend — API client + search integration + RemoteUserCard - -**Files:** -- Modify: `thoughts-frontend/lib/api.ts` -- Modify: `thoughts-frontend/app/search/page.tsx` -- Create: `thoughts-frontend/components/remote-user-card.tsx` - -- [ ] **Step 1: Add types and API functions to `lib/api.ts`** - -After the `UserSchema` block (around line 15), add: - -```typescript -export const RemoteActorSchema = z.object({ - handle: z.string(), - displayName: z.string().nullable(), - avatarUrl: z.string().nullable(), - url: z.string(), -}); -export type RemoteActor = z.infer; -``` - -After the existing `followUser` and `unfollowUser` functions, add: - -```typescript -export const lookupRemoteActor = (handle: string, token: string | null) => - apiFetch( - `/federation/lookup?handle=${encodeURIComponent(handle)}`, - {}, - RemoteActorSchema, - token - ); - -export const followRemoteUser = (handle: string, token: string) => - apiFetch( - `/federation/follow`, - { method: "POST", body: JSON.stringify({ handle }) }, - z.null(), - token - ); -``` - -- [ ] **Step 2: Create `RemoteUserCard` component** - -Create `thoughts-frontend/components/remote-user-card.tsx`: - -```typescript -"use client"; - -import { useState } from "react"; -import { useAuth } from "@/hooks/use-auth"; -import { followRemoteUser, RemoteActor } from "@/lib/api"; -import { Button } from "@/components/ui/button"; -import { UserAvatar } from "@/components/user-avatar"; -import { toast } from "sonner"; -import { UserPlus } from "lucide-react"; - -interface RemoteUserCardProps { - actor: RemoteActor; -} - -export function RemoteUserCard({ actor }: RemoteUserCardProps) { - const [followed, setFollowed] = useState(false); - const [loading, setLoading] = useState(false); - const { token } = useAuth(); - - const handleFollow = async () => { - if (!token) { - toast.error("You must be logged in to follow users."); - return; - } - setLoading(true); - try { - await followRemoteUser(actor.handle, token); - setFollowed(true); - toast.success(`Follow request sent to ${actor.handle}`); - } catch { - toast.error("Failed to send follow request."); - } finally { - setLoading(false); - } - }; - - return ( -
-
- -
-

{actor.displayName ?? actor.handle}

-

{actor.handle}

-
-
- -
- ); -} -``` - -Note: Check how `UserAvatar` is used in other components (e.g. `user-list-card.tsx`) to confirm the prop names match. - -- [ ] **Step 3: Check `UserAvatar` props** - -```bash -grep -n "UserAvatar\|avatarUrl\|username" /mnt/drive/dev/thoughts/thoughts-frontend/components/user-avatar.tsx | head -10 -``` - -Adjust the `UserAvatar` usage in `RemoteUserCard` to match the actual props. - -- [ ] **Step 4: Update `app/search/page.tsx` to detect handles and show remote result** - -Replace the file with: - -```typescript -import { cookies } from "next/headers"; -import { getMe, search, lookupRemoteActor, User, RemoteActor } from "@/lib/api"; -import { UserListCard } from "@/components/user-list-card"; -import { RemoteUserCard } from "@/components/remote-user-card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ThoughtList } from "@/components/thought-list"; - -const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/; - -interface SearchPageProps { - searchParams: Promise<{ q?: string }>; -} - -export default async function SearchPage({ searchParams }: SearchPageProps) { - const { q } = await searchParams; - const query = q || ""; - const token = (await cookies()).get("auth_token")?.value ?? null; - - if (!query) { - return ( -
-

Search Thoughts

-

- Find users and thoughts across the platform. -

-
- ); - } - - const isHandle = HANDLE_RE.test(query); - - const [results, remoteActor, me] = await Promise.all([ - isHandle ? null : search(query, token).catch(() => null), - isHandle ? lookupRemoteActor(query, token).catch(() => null) : null, - token ? getMe(token).catch(() => null) : null, - ]); - - const authorDetails = new Map(); - if (results) { - results.users.forEach((user: User) => { - authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); - }); - } - - return ( -
-
-

Search Results

-

- Showing results for: "{query}" -

-
-
- {isHandle ? ( - remoteActor ? ( -
-

Remote user

- -
- ) : ( -

- No user found at {query} -

- ) - ) : results ? ( - - - - Thoughts ({results.thoughts.length}) - - - Users ({results.users.length}) - - - - - - - - - - ) : ( -

- No results found or an error occurred. -

- )} -
-
- ); -} -``` - -- [ ] **Step 5: Type-check the frontend** - -```bash -cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 -``` - -Expected: no errors. Fix any type mismatches before continuing. - -- [ ] **Step 6: Commit** - -```bash -cd /mnt/drive/dev/thoughts/thoughts-frontend -git add lib/api.ts app/search/page.tsx components/remote-user-card.tsx -cd .. -git commit -m "feat(frontend): remote actor lookup and follow from search page" -``` - ---- - -## Self-Review - -**Spec coverage check:** -- ✅ `FederationActionPort` trait with `lookup_actor` + `follow_remote` — Task 1 -- ✅ `avatar_url` on `RemoteActor` — Task 1 -- ✅ `ExternalService` error variant — Task 1 -- ✅ `ActivityPubService` impl — Task 2 -- ✅ Bootstrap refactor + `AppState.federation` — Task 3 -- ✅ `RemoteActorResponse` + `FollowRemoteRequest` — Task 4 -- ✅ `/federation/lookup` + `/federation/follow` endpoints — Task 4 -- ✅ Error mapping (ExternalService → 502) — Task 3 -- ✅ Frontend API client additions — Task 5 -- ✅ Handle detection regex in search page — Task 5 -- ✅ `RemoteUserCard` component — Task 5 - -**Placeholder check:** None found. - -**Type consistency check:** -- `RemoteActor.avatar_url: Option` used in Task 1, mapped from `DbActor.avatar_url: Option` in Task 2 via `.map(|u| u.to_string())` ✅ -- `FollowRemoteRequest.handle` → `follow_remote(&uid, &body.handle)` ✅ -- `RemoteActorResponse` fields match `RemoteActor` domain model fields ✅ -- Frontend `RemoteActorSchema` camelCase fields match `#[serde(rename_all = "camelCase")]` on `RemoteActorResponse` ✅ -- `UserId::inner()` — verified as an assumption in Task 2 Step 4 with an explicit check step ✅ diff --git a/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md b/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md deleted file mode 100644 index c4a750e..0000000 --- a/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md +++ /dev/null @@ -1,246 +0,0 @@ -# v1 Parity Gaps Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close four endpoints present in v1 but missing from v2: `GET /users/me`, `GET /users/{username}/thoughts`, `GET /tags/{name}`, and `GET /health`. - -**Architecture:** All data layer work is already done — repositories, use cases, and response types exist. This plan is purely presentation layer additions: new handler functions in existing files, new routes registered in `routes.rs`. No domain or application changes needed. - -**Tech Stack:** axum 0.8, existing AppState ports - ---- - -## File Map - -``` -Modify: crates/presentation/src/handlers/users.rs ← add get_me handler -Modify: crates/presentation/src/handlers/feed.rs ← add user_thoughts + tag_thoughts handlers -Modify: crates/presentation/src/routes.rs ← register 4 new routes -Create: crates/presentation/src/handlers/health.rs ← health check handler -Modify: crates/presentation/src/handlers/mod.rs ← pub mod health -``` - ---- - -### Task 1: GET /users/me, GET /users/{username}/thoughts, GET /tags/{name} - -**Files:** -- Modify: `crates/presentation/src/handlers/users.rs` -- Modify: `crates/presentation/src/handlers/feed.rs` -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Add `get_me` handler** to `crates/presentation/src/handlers/users.rs` — append after `patch_profile`: - -```rust -pub async fn get_me(State(s): State, AuthUser(uid): AuthUser) -> Result, ApiError> { - let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; - Ok(Json(to_user_response(&user))) -} -``` - -- [ ] **Add `user_thoughts_handler` and `tag_thoughts_handler`** to `crates/presentation/src/handlers/feed.rs` — append after `get_followers_handler`: - -```rust -pub async fn user_thoughts_handler( - State(s): State, - Path(username): Path, - Query(q): Query, -) -> Result, ApiError> { - use application::use_cases::feed::get_user_feed; - let user = get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_user_feed(&*s.thoughts, &user.id, page).await?; - Ok(Json(serde_json::json!({ - "total": result.total, - "page": result.page, - "per_page": result.per_page, - "items": result.items.iter().map(|e| serde_json::json!({ - "id": e.thought.id.as_uuid(), - "content": e.thought.content.as_str(), - "visibility": e.thought.visibility.as_str(), - "like_count": e.like_count, - "boost_count": e.boost_count, - "reply_count": e.reply_count, - "created_at": e.thought.created_at, - "updated_at": e.thought.updated_at, - })).collect::>() - }))) -} - -pub async fn tag_thoughts_handler( - State(s): State, - Path(tag_name): Path, - Query(q): Query, -) -> Result, ApiError> { - use application::use_cases::feed::get_by_tag; - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_by_tag(&*s.tags, &tag_name, page).await?; - Ok(Json(serde_json::json!({ - "tag": tag_name, - "total": result.total, - "page": result.page, - "per_page": result.per_page, - "items": result.items.iter().map(|t| serde_json::json!({ - "id": t.id.as_uuid(), - "content": t.content.as_str(), - "visibility": t.visibility.as_str(), - "created_at": t.created_at, - })).collect::>() - }))) -} -``` - -Note: `get_user_by_username`, `PageParams`, `PaginationQuery` are already imported in `feed.rs`. Only `get_user_feed` and `get_by_tag` need adding to the `use application::use_cases::feed::` import line at the top. Check the existing import and extend it. - -- [ ] **Register the three new routes** in `crates/presentation/src/routes.rs` — add to `api_routes`: - -```rust - // GET /users/me must be registered before /users/{username} to take precedence - .route("/users/me", get(users::get_me).patch(users::patch_profile)) - .route("/users/{username}/thoughts", get(feed::user_thoughts_handler)) - .route("/tags/{name}", get(feed::tag_thoughts_handler)) -``` - -**Important:** The existing routes have `/users/me` only for PATCH. Replace that line: - -Find: -```rust - .route("/users/me", patch(users::patch_profile)) -``` - -Replace with: -```rust - .route("/users/me", get(users::get_me).patch(users::patch_profile)) -``` - -And add `/users/{username}/thoughts` and `/tags/{name}` anywhere in `api_routes`. - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Smoke test:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \ -BASE_URL=http://localhost:3000 cargo run -p presentation & -sleep 2 - -TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \ - -H 'content-type: application/json' \ - -d '{"username":"parity","email":"parity@test.com","password":"pw"}' | jq -r .token) - -# GET /users/me -curl -s http://localhost:3000/users/me -H "Authorization: Bearer $TOKEN" | jq .username - -# POST a thought then fetch by tag (needs tag to exist) -curl -s -X POST http://localhost:3000/thoughts \ - -H 'content-type: application/json' \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"content":"hello world"}' > /dev/null - -# GET /users/{username}/thoughts -curl -s "http://localhost:3000/users/parity/thoughts" | jq '.total' - -# GET /tags/{name} (tag may be empty if no tagged thoughts) -curl -s "http://localhost:3000/tags/welcome" | jq '.tag' - -kill %1 -``` - -Expected: `username` = `"parity"`, `total` = 1, `tag` = `"welcome"`. - -- [ ] **Commit:** - -```bash -git add crates/presentation/src/handlers/users.rs \ - crates/presentation/src/handlers/feed.rs \ - crates/presentation/src/routes.rs -git commit -m "feat(presentation): GET /users/me, GET /users/{username}/thoughts, GET /tags/{name}" -``` - ---- - -### Task 2: GET /health - -**Files:** -- Create: `crates/presentation/src/handlers/health.rs` -- Modify: `crates/presentation/src/handlers/mod.rs` -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Create `crates/presentation/src/handlers/health.rs`:** - -```rust -use axum::{extract::State, Json}; -use crate::state::AppState; - -pub async fn health_handler(State(s): State) -> Json { - // Cheap liveness check: verify DB connectivity - let db_ok = s.users.list_with_stats().await.is_ok(); - Json(serde_json::json!({ - "status": if db_ok { "ok" } else { "degraded" }, - "db": if db_ok { "connected" } else { "error" }, - })) -} -``` - -- [ ] **Add `pub mod health;`** to `crates/presentation/src/handlers/mod.rs`. - -- [ ] **Register the route** in `crates/presentation/src/routes.rs` — add to `api_routes`: - -```rust - .route("/health", get(health::health_handler)) -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Run full test suite:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 -``` - -Expected: all tests pass. - -- [ ] **Smoke test:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \ -BASE_URL=http://localhost:3000 cargo run -p presentation & -sleep 2 -curl -s http://localhost:3000/health | jq . -kill %1 -``` - -Expected: `{"status":"ok","db":"connected"}`. - -- [ ] **Commit:** - -```bash -git add crates/presentation/src/handlers/health.rs \ - crates/presentation/src/handlers/mod.rs \ - crates/presentation/src/routes.rs -git commit -m "feat(presentation): GET /health endpoint" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `GET /users/me` — returns authenticated user's profile (Task 1) -- ✅ `GET /users/{username}/thoughts` — paginated thought list for any user (Task 1) -- ✅ `GET /tags/{name}` — paginated thoughts by tag name (Task 1) -- ✅ `GET /health` — DB connectivity check returning JSON status (Task 2) - -**Placeholder scan:** None. - -**Type consistency:** -- `get_me` returns `Json` — same type as `get_user`, consistent -- `user_thoughts_handler` calls `get_user_feed(&*s.thoughts, ...)` — matches use case signature in `feed.rs` -- `tag_thoughts_handler` calls `get_by_tag(&*s.tags, ...)` — matches use case signature -- `health_handler` calls `s.users.list_with_stats()` — exists on `UserRepository` port - -**Notes:** -- `/users/me` with GET + PATCH on the same route object — axum handles this with `.get(...).patch(...)` -- Static `/users/me` takes precedence over `/users/{username}` in axum route matching, so no conflict even though both patterns exist -- `list_with_stats()` does a DB query; acceptable for a health check — returns quickly and confirms DB connectivity -- `/tags/{name}` matches `{name}` not `{tagName}` — consistent with Rust naming convention diff --git a/docs/superpowers/plans/2026-05-14-v2-plan1-core.md b/docs/superpowers/plans/2026-05-14-v2-plan1-core.md deleted file mode 100644 index ab0236a..0000000 --- a/docs/superpowers/plans/2026-05-14-v2-plan1-core.md +++ /dev/null @@ -1,3529 +0,0 @@ -# Thoughts v2 — Plan 1: Core (Domain + Postgres + Auth + REST API) - -> **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:** Build the thoughts v2 backend core — clean domain, postgres adapter, auth, and a fully working JSON REST API — without federation or event system. - -**Architecture:** Hexagonal. `crates/domain` defines all entities and port traits. `crates/application` contains use cases that depend only on domain traits. `crates/adapters/postgres` and `crates/adapters/auth` implement those traits. `crates/presentation` wires concrete adapters into `Arc` via axum state and serves the REST API. No layer imports from a layer below it in the dependency graph except via traits. - -**Tech Stack:** Rust 2021, axum 0.8, sqlx 0.8 (postgres, no ORM), tokio, jsonwebtoken 9, argon2 0.5, serde, thiserror 2 - -**Subsequent plans:** Plan 2 = search (postgres-search), Plan 3 = events+worker (nats), Plan 4 = federation (activitypub). - ---- - -## File Map - -``` -Cargo.toml ← workspace root (NEW) -crates/domain/Cargo.toml -crates/domain/src/lib.rs -crates/domain/src/errors.rs -crates/domain/src/value_objects.rs -crates/domain/src/models/mod.rs -crates/domain/src/models/user.rs -crates/domain/src/models/thought.rs -crates/domain/src/models/social.rs ← Like, Boost, Follow, Block -crates/domain/src/models/tag.rs -crates/domain/src/models/api_key.rs -crates/domain/src/models/top_friend.rs -crates/domain/src/models/notification.rs -crates/domain/src/models/remote_actor.rs -crates/domain/src/models/feed.rs ← FeedEntry, PageParams, Paginated, UserSummary -crates/domain/src/ports.rs -crates/domain/src/events.rs -crates/domain/src/testing.rs ← in-memory impls (test-helpers feature) -crates/application/Cargo.toml -crates/application/src/lib.rs -crates/application/src/use_cases/mod.rs -crates/application/src/use_cases/auth.rs -crates/application/src/use_cases/thoughts.rs -crates/application/src/use_cases/social.rs -crates/application/src/use_cases/feed.rs -crates/application/src/use_cases/profile.rs -crates/application/src/use_cases/api_keys.rs -crates/api-types/Cargo.toml -crates/api-types/src/lib.rs -crates/api-types/src/requests.rs -crates/api-types/src/responses.rs -crates/adapters/postgres/Cargo.toml -crates/adapters/postgres/src/lib.rs -crates/adapters/postgres/src/user.rs -crates/adapters/postgres/src/thought.rs -crates/adapters/postgres/src/follow.rs -crates/adapters/postgres/src/block.rs -crates/adapters/postgres/src/like.rs -crates/adapters/postgres/src/boost.rs -crates/adapters/postgres/src/tag.rs -crates/adapters/postgres/src/api_key.rs -crates/adapters/postgres/src/top_friend.rs -crates/adapters/postgres/src/notification.rs -crates/adapters/postgres/src/remote_actor.rs -crates/adapters/postgres/src/feed.rs -crates/adapters/postgres/migrations/001_initial_schema.sql -crates/adapters/postgres/migrations/002_federation_columns.sql -crates/adapters/postgres/migrations/003_new_tables.sql -crates/adapters/auth/Cargo.toml -crates/adapters/auth/src/lib.rs -crates/presentation/Cargo.toml -crates/presentation/src/main.rs -crates/presentation/src/state.rs -crates/presentation/src/errors.rs -crates/presentation/src/extractors.rs -crates/presentation/src/routes.rs -crates/presentation/src/handlers/mod.rs -crates/presentation/src/handlers/auth.rs -crates/presentation/src/handlers/thoughts.rs -crates/presentation/src/handlers/feed.rs -crates/presentation/src/handlers/social.rs -crates/presentation/src/handlers/users.rs -crates/presentation/src/handlers/notifications.rs -crates/presentation/src/handlers/api_keys.rs -# Stub crates (empty, referenced in workspace for future plans): -crates/adapters/postgres-search/Cargo.toml + src/lib.rs -crates/adapters/postgres-federation/Cargo.toml + src/lib.rs -crates/adapters/activitypub-base/Cargo.toml + src/lib.rs -crates/adapters/activitypub/Cargo.toml + src/lib.rs -crates/adapters/nats/Cargo.toml + src/lib.rs -crates/adapters/event-payload/Cargo.toml + src/lib.rs -crates/adapters/event-publisher/Cargo.toml + src/lib.rs -crates/worker/Cargo.toml + src/main.rs -``` - ---- - -### Task 1: Workspace scaffold - -**Files:** `Cargo.toml` (root), all crate `Cargo.toml`s, empty `src/lib.rs` or `src/main.rs` stubs - -- [ ] **Create root `Cargo.toml`:** - -```toml -[workspace] -members = [ - "crates/domain", - "crates/application", - "crates/api-types", - "crates/presentation", - "crates/worker", - "crates/adapters/postgres", - "crates/adapters/postgres-search", - "crates/adapters/postgres-federation", - "crates/adapters/activitypub-base", - "crates/adapters/activitypub", - "crates/adapters/auth", - "crates/adapters/nats", - "crates/adapters/event-payload", - "crates/adapters/event-publisher", -] -resolver = "2" - -[workspace.dependencies] -tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0" -thiserror = "2.0" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -async-trait = "0.1" -uuid = { version = "1.0", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] } -axum = { version = "0.8", features = ["macros"] } -tower-http = { version = "0.6", features = ["cors", "trace"] } -futures = "0.3" -dotenvy = "0.15" - -domain = { path = "crates/domain" } -application = { path = "crates/application" } -api-types = { path = "crates/api-types" } -postgres = { path = "crates/adapters/postgres" } -postgres-search = { path = "crates/adapters/postgres-search" } -postgres-federation = { path = "crates/adapters/postgres-federation" } -activitypub-base = { path = "crates/adapters/activitypub-base" } -activitypub = { path = "crates/adapters/activitypub" } -auth = { path = "crates/adapters/auth" } -nats = { path = "crates/adapters/nats" } -event-payload = { path = "crates/adapters/event-payload" } -event-publisher = { path = "crates/adapters/event-publisher" } -``` - -- [ ] **Create `crates/domain/Cargo.toml`:** - -```toml -[package] -name = "domain" -version = "0.1.0" -edition = "2021" - -[features] -test-helpers = [] - -[dependencies] -async-trait = { workspace = true } -thiserror = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -serde = { workspace = true } -futures = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["full"] } -``` - -- [ ] **Create `crates/application/Cargo.toml`:** - -```toml -[package] -name = "application" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -async-trait = { workspace = true } -thiserror = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["full"] } -domain = { workspace = true, features = ["test-helpers"] } -``` - -- [ ] **Create `crates/api-types/Cargo.toml`:** - -```toml -[package] -name = "api-types" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -``` - -- [ ] **Create `crates/presentation/Cargo.toml`:** - -```toml -[package] -name = "presentation" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "thoughts" -path = "src/main.rs" - -[dependencies] -domain = { workspace = true } -application = { workspace = true } -api-types = { workspace = true } -postgres = { workspace = true } -auth = { workspace = true } -axum = { workspace = true } -tower-http = { workspace = true } -tokio = { workspace = true, features = ["full"] } -serde = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -dotenvy = { workspace = true } -async-trait = { workspace = true } - -[dev-dependencies] -http-body-util = "0.1" -tower = "0.5" -domain = { workspace = true, features = ["test-helpers"] } -``` - -- [ ] **Create `crates/adapters/postgres/Cargo.toml`:** - -```toml -[package] -name = "postgres" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -sqlx = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -async-trait = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["full"] } -sqlx = { workspace = true, features = ["migrate"] } -``` - -- [ ] **Create `crates/adapters/auth/Cargo.toml`:** - -```toml -[package] -name = "auth" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -async-trait = { workspace = true } -thiserror = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tokio = { workspace = true } -jsonwebtoken = "9" -argon2 = "0.5" -``` - -- [ ] **Create stub crates** — for each of the 8 remaining crates create a `Cargo.toml` with just `[package]` (name/version/edition) and an empty `src/lib.rs` (or `src/main.rs` for worker): - -``` -crates/adapters/postgres-search/ → name = "postgres-search" -crates/adapters/postgres-federation/→ name = "postgres-federation" -crates/adapters/activitypub-base/ → name = "activitypub-base" -crates/adapters/activitypub/ → name = "activitypub" -crates/adapters/nats/ → name = "nats" -crates/adapters/event-payload/ → name = "event-payload" -crates/adapters/event-publisher/ → name = "event-publisher" -crates/worker/ → name = "worker", bin src/main.rs = fn main(){} -``` - -- [ ] **Create empty `src/lib.rs`** for domain, application, api-types, postgres, auth, and all stub crates. - -- [ ] **Run:** `cargo check` - Expected: compiles with no errors (only "unused" warnings ok). - -- [ ] **Commit:** -```bash -git add Cargo.toml crates/ -git commit -m "chore: scaffold v2 workspace" -``` - ---- - -### Task 2: Domain — errors and value objects - -**Files:** `crates/domain/src/errors.rs`, `crates/domain/src/value_objects.rs`, update `crates/domain/src/lib.rs` - -- [ ] **Write the test** in `crates/domain/src/value_objects.rs` (bottom of file): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn username_rejects_empty() { - assert!(Username::new("").is_err()); - } - #[test] - fn username_rejects_too_long() { - assert!(Username::new("a".repeat(33)).is_err()); - } - #[test] - fn username_rejects_invalid_chars() { - assert!(Username::new("hello world").is_err()); - } - #[test] - fn username_accepts_valid() { - assert!(Username::new("hello_123").is_ok()); - } - #[test] - fn content_local_rejects_over_128() { - assert!(Content::new_local("a".repeat(129)).is_err()); - } - #[test] - fn content_local_accepts_128() { - assert!(Content::new_local("a".repeat(128)).is_ok()); - } - #[test] - fn email_rejects_no_at() { - assert!(Email::new("notanemail").is_err()); - } -} -``` - -- [ ] **Run:** `cargo test -p domain` — Expected: FAIL (no source yet). - -- [ ] **Write `crates/domain/src/errors.rs`:** - -```rust -use thiserror::Error; - -#[derive(Debug, Error, Clone)] -pub enum DomainError { - #[error("not found")] - NotFound, - #[error("unauthorized")] - Unauthorized, - #[error("forbidden")] - Forbidden, - #[error("conflict: {0}")] - Conflict(String), - #[error("invalid input: {0}")] - InvalidInput(String), - #[error("internal error: {0}")] - Internal(String), -} -``` - -- [ ] **Write `crates/domain/src/value_objects.rs`:** - -```rust -use uuid::Uuid; -use crate::errors::DomainError; - -macro_rules! uuid_id { - ($name:ident) => { - #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] - pub struct $name(Uuid); - impl $name { - pub fn new() -> Self { Self(Uuid::new_v4()) } - pub fn from_uuid(u: Uuid) -> Self { Self(u) } - pub fn as_uuid(&self) -> Uuid { self.0 } - } - impl Default for $name { - fn default() -> Self { Self::new() } - } - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } - } - }; -} - -uuid_id!(UserId); -uuid_id!(ThoughtId); -uuid_id!(LikeId); -uuid_id!(BoostId); -uuid_id!(ApiKeyId); -uuid_id!(NotificationId); - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Username(String); -impl Username { - pub fn new(s: impl Into) -> Result { - let s = s.into(); - if s.is_empty() || s.len() > 32 { - return Err(DomainError::InvalidInput("username: 1-32 chars".into())); - } - if !s.chars().all(|c| c.is_alphanumeric() || c == '_') { - return Err(DomainError::InvalidInput("username: alphanumeric or underscore only".into())); - } - Ok(Self(s)) - } - pub fn from_trusted(s: String) -> Self { Self(s) } - pub fn as_str(&self) -> &str { &self.0 } -} -impl std::fmt::Display for Username { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Email(String); -impl Email { - pub fn new(s: impl Into) -> Result { - let s = s.into().to_lowercase(); - if !s.contains('@') || s.len() > 255 { - return Err(DomainError::InvalidInput("invalid email".into())); - } - Ok(Self(s)) - } - pub fn from_trusted(s: String) -> Self { Self(s) } - pub fn as_str(&self) -> &str { &self.0 } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PasswordHash(pub String); - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Content(String); -impl Content { - pub fn new_local(s: impl Into) -> Result { - let s = s.into(); - if s.is_empty() || s.len() > 128 { - return Err(DomainError::InvalidInput("content: 1-128 chars".into())); - } - Ok(Self(s)) - } - pub fn new_remote(s: impl Into) -> Self { Self(s.into()) } - pub fn as_str(&self) -> &str { &self.0 } -} -impl std::fmt::Display for Content { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } -} - -// re-export tests at bottom (already shown above) -``` - -- [ ] **Update `crates/domain/src/lib.rs`:** - -```rust -pub mod errors; -pub mod value_objects; -// remaining modules added in later tasks -``` - -- [ ] **Run:** `cargo test -p domain` — Expected: all tests PASS. - -- [ ] **Commit:** -```bash -git add crates/domain/ -git commit -m "feat(domain): errors and value objects" -``` - ---- - -### Task 3: Domain — models - -**Files:** `crates/domain/src/models/mod.rs` and all model files, update `lib.rs` - -- [ ] **Write `crates/domain/src/models/user.rs`:** - -```rust -use chrono::{DateTime, Utc}; -use crate::value_objects::{UserId, Username, Email, PasswordHash}; - -#[derive(Debug, Clone)] -pub struct User { - pub id: UserId, - pub username: Username, - pub email: Email, - pub password_hash: PasswordHash, - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub header_url: Option, - pub custom_css: Option, - pub local: bool, - pub ap_id: Option, - pub inbox_url: Option, - pub public_key: Option, - pub private_key: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl User { - pub fn new_local(id: UserId, username: Username, email: Email, password_hash: PasswordHash) -> Self { - let now = Utc::now(); - Self { - id, username, email, password_hash, - display_name: None, bio: None, avatar_url: None, header_url: None, - custom_css: None, local: true, ap_id: None, inbox_url: None, - public_key: None, private_key: None, - created_at: now, updated_at: now, - } - } -} -``` - -- [ ] **Write `crates/domain/src/models/thought.rs`:** - -```rust -use chrono::{DateTime, Utc}; -use crate::value_objects::{ThoughtId, UserId, Content}; - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum Visibility { - Public, Followers, Unlisted, Direct, -} -impl Visibility { - pub fn from_str(s: &str) -> Self { - match s { "followers" => Self::Followers, "unlisted" => Self::Unlisted, "direct" => Self::Direct, _ => Self::Public } - } - pub fn as_str(&self) -> &str { - match self { Self::Public => "public", Self::Followers => "followers", Self::Unlisted => "unlisted", Self::Direct => "direct" } - } -} - -#[derive(Debug, Clone)] -pub struct Thought { - pub id: ThoughtId, - pub user_id: UserId, - pub content: Content, - pub in_reply_to_id: Option, - pub in_reply_to_url: Option, - pub ap_id: Option, - pub visibility: Visibility, - pub content_warning: Option, - pub sensitive: bool, - pub local: bool, - pub created_at: DateTime, - pub updated_at: Option>, -} - -impl Thought { - pub fn new_local( - id: ThoughtId, user_id: UserId, content: Content, - in_reply_to_id: Option, visibility: Visibility, - content_warning: Option, sensitive: bool, - ) -> Self { - Self { - id, user_id, content, in_reply_to_id, in_reply_to_url: None, ap_id: None, - visibility, content_warning, sensitive, local: true, - created_at: Utc::now(), updated_at: None, - } - } -} -``` - -- [ ] **Write `crates/domain/src/models/social.rs`:** - -```rust -use chrono::{DateTime, Utc}; -use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId}; - -#[derive(Debug, Clone)] -pub struct Like { - pub id: LikeId, - pub user_id: UserId, - pub thought_id: ThoughtId, - pub ap_id: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct Boost { - pub id: BoostId, - pub user_id: UserId, - pub thought_id: ThoughtId, - pub ap_id: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FollowState { Pending, Accepted, Rejected } -impl FollowState { - pub fn from_str(s: &str) -> Self { - match s { "pending" => Self::Pending, "rejected" => Self::Rejected, _ => Self::Accepted } - } - pub fn as_str(&self) -> &str { - match self { Self::Pending => "pending", Self::Accepted => "accepted", Self::Rejected => "rejected" } - } -} - -#[derive(Debug, Clone)] -pub struct Follow { - pub follower_id: UserId, - pub following_id: UserId, - pub state: FollowState, - pub ap_id: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct Block { - pub blocker_id: UserId, - pub blocked_id: UserId, - pub created_at: DateTime, -} -``` - -- [ ] **Write `crates/domain/src/models/tag.rs`:** - -```rust -#[derive(Debug, Clone)] -pub struct Tag { pub id: i32, pub name: String } -``` - -- [ ] **Write `crates/domain/src/models/api_key.rs`:** - -```rust -use chrono::{DateTime, Utc}; -use crate::value_objects::{ApiKeyId, UserId}; - -#[derive(Debug, Clone)] -pub struct ApiKey { - pub id: ApiKeyId, - pub user_id: UserId, - pub key_hash: String, - pub name: String, - pub created_at: DateTime, -} -``` - -- [ ] **Write `crates/domain/src/models/top_friend.rs`:** - -```rust -use crate::value_objects::UserId; - -#[derive(Debug, Clone)] -pub struct TopFriend { pub user_id: UserId, pub friend_id: UserId, pub position: i16 } -``` - -- [ ] **Write `crates/domain/src/models/notification.rs`:** - -```rust -use chrono::{DateTime, Utc}; -use crate::value_objects::{NotificationId, UserId, ThoughtId}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NotificationType { Like, Boost, Follow, Mention, Reply } -impl NotificationType { - pub fn from_str(s: &str) -> Self { - match s { "like" => Self::Like, "boost" => Self::Boost, "follow" => Self::Follow, "mention" => Self::Mention, _ => Self::Reply } - } - pub fn as_str(&self) -> &str { - match self { Self::Like => "like", Self::Boost => "boost", Self::Follow => "follow", Self::Mention => "mention", Self::Reply => "reply" } - } -} - -#[derive(Debug, Clone)] -pub struct Notification { - pub id: NotificationId, - pub user_id: UserId, - pub notification_type: NotificationType, - pub from_user_id: Option, - pub thought_id: Option, - pub read: bool, - pub created_at: DateTime, -} -``` - -- [ ] **Write `crates/domain/src/models/remote_actor.rs`:** - -```rust -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone)] -pub struct RemoteActor { - pub url: String, - pub handle: String, - pub display_name: Option, - pub inbox_url: String, - pub shared_inbox_url: Option, - pub public_key: String, - pub last_fetched_at: DateTime, -} -``` - -- [ ] **Write `crates/domain/src/models/feed.rs`:** - -```rust -use crate::models::{user::User, thought::Thought}; -use crate::value_objects::UserId; - -#[derive(Debug, Clone)] -pub struct UserSummary { - pub id: UserId, - pub username: String, - pub display_name: Option, - pub avatar_url: Option, - pub bio: Option, - pub thought_count: i64, - pub follower_count: i64, - pub following_count: i64, -} - -#[derive(Debug, Clone)] -pub struct FeedEntry { - pub thought: Thought, - pub author: User, - pub like_count: i64, - pub boost_count: i64, - pub reply_count: i64, - pub liked_by_viewer: bool, - pub boosted_by_viewer: bool, -} - -#[derive(Debug, Clone)] -pub struct PageParams { pub page: u64, pub per_page: u64 } -impl PageParams { - pub fn offset(&self) -> i64 { ((self.page.saturating_sub(1)) * self.per_page) as i64 } - pub fn limit(&self) -> i64 { self.per_page as i64 } -} - -#[derive(Debug, Clone)] -pub struct Paginated { - pub items: Vec, - pub total: i64, - pub page: u64, - pub per_page: u64, -} -``` - -- [ ] **Write `crates/domain/src/models/mod.rs`:** - -```rust -pub mod api_key; -pub mod feed; -pub mod notification; -pub mod remote_actor; -pub mod social; -pub mod tag; -pub mod thought; -pub mod top_friend; -pub mod user; -``` - -- [ ] **Update `crates/domain/src/lib.rs`:** - -```rust -pub mod errors; -pub mod events; -pub mod models; -pub mod ports; -pub mod value_objects; - -#[cfg(any(test, feature = "test-helpers"))] -pub mod testing; -``` - -- [ ] **Run:** `cargo check -p domain` — Expected: no errors. - -- [ ] **Commit:** -```bash -git add crates/domain/ -git commit -m "feat(domain): models" -``` - ---- - -### Task 4: Domain — ports, events, and in-memory test helpers - -**Files:** `crates/domain/src/ports.rs`, `crates/domain/src/events.rs`, `crates/domain/src/testing.rs` - -- [ ] **Write `crates/domain/src/events.rs`:** - -```rust -use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId}; - -#[derive(Debug, Clone)] -pub enum DomainEvent { - ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option }, - ThoughtDeleted { thought_id: ThoughtId, user_id: UserId }, - ThoughtUpdated { thought_id: ThoughtId, user_id: UserId }, - LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId }, - LikeRemoved { user_id: UserId, thought_id: ThoughtId }, - BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId }, - BoostRemoved { user_id: UserId, thought_id: ThoughtId }, - FollowRequested { follower_id: UserId, following_id: UserId }, - FollowAccepted { follower_id: UserId, following_id: UserId }, - FollowRejected { follower_id: UserId, following_id: UserId }, - Unfollowed { follower_id: UserId, following_id: UserId }, - UserBlocked { blocker_id: UserId, blocked_id: UserId }, -} - -pub struct EventEnvelope { - pub event: DomainEvent, - pub ack: Box, - pub nack: Box, -} -impl std::fmt::Debug for EventEnvelope { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EventEnvelope").field("event", &self.event).finish() - } -} -``` - -- [ ] **Write `crates/domain/src/ports.rs`:** - -```rust -use async_trait::async_trait; -use crate::{ - errors::DomainError, - events::{DomainEvent, EventEnvelope}, - models::{ - api_key::ApiKey, - feed::{FeedEntry, PageParams, Paginated, UserSummary}, - notification::Notification, - remote_actor::RemoteActor, - social::{Block, Boost, Follow, FollowState, Like}, - tag::Tag, - thought::Thought, - top_friend::TopFriend, - user::User, - }, - value_objects::{ApiKeyId, BoostId, Content, Email, LikeId, NotificationId, PasswordHash, ThoughtId, UserId, Username}, -}; - -pub struct GeneratedToken { pub token: String, pub user_id: UserId } - -#[async_trait] -pub trait AuthService: Send + Sync { - fn generate_token(&self, user_id: &UserId) -> Result; - fn validate_token(&self, token: &str) -> Result; -} - -#[async_trait] -pub trait PasswordHasher: Send + Sync { - async fn hash(&self, plain: &str) -> Result; - async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result; -} - -#[async_trait] -pub trait EventPublisher: Send + Sync { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; -} - -pub trait EventConsumer: Send + Sync { - fn consume(&self) -> futures::stream::BoxStream<'_, Result>; -} - -#[async_trait] -pub trait UserRepository: Send + Sync { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; - async fn find_by_username(&self, username: &Username) -> Result, DomainError>; - async fn find_by_email(&self, email: &Email) -> Result, DomainError>; - async fn save(&self, user: &User) -> Result<(), DomainError>; - async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError>; - async fn list_with_stats(&self) -> Result, DomainError>; -} - -#[async_trait] -pub trait ThoughtRepository: Send + Sync { - async fn save(&self, thought: &Thought) -> Result<(), DomainError>; - async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError>; - async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>; - async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>; - async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError>; - async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; -} - -#[async_trait] -pub trait LikeRepository: Send + Sync { - async fn save(&self, like: &Like) -> Result<(), DomainError>; - async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError>; - async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; -} - -#[async_trait] -pub trait BoostRepository: Send + Sync { - async fn save(&self, boost: &Boost) -> Result<(), DomainError>; - async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError>; - async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; -} - -#[async_trait] -pub trait FollowRepository: Send + Sync { - async fn save(&self, follow: &Follow) -> Result<(), DomainError>; - async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError>; - async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError>; - async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError>; - async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; - async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; - async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError>; -} - -#[async_trait] -pub trait BlockRepository: Send + Sync { - async fn save(&self, block: &Block) -> Result<(), DomainError>; - async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError>; - async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result; -} - -#[async_trait] -pub trait TagRepository: Send + Sync { - async fn find_or_create(&self, name: &str) -> Result; - async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError>; - async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError>; - async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError>; - async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result, DomainError>; -} - -#[async_trait] -pub trait ApiKeyRepository: Send + Sync { - async fn save(&self, key: &ApiKey) -> Result<(), DomainError>; - async fn find_by_hash(&self, key_hash: &str) -> Result, DomainError>; - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; - async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait TopFriendRepository: Send + Sync { - async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError>; - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; -} - -#[async_trait] -pub trait NotificationRepository: Send + Sync { - async fn save(&self, n: &Notification) -> Result<(), DomainError>; - async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; - async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError>; - async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait RemoteActorRepository: Send + Sync { - async fn upsert(&self, actor: &RemoteActor) -> Result<(), DomainError>; - async fn find_by_url(&self, url: &str) -> Result, DomainError>; -} - -#[async_trait] -pub trait FeedRepository: Send + Sync { - async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; - async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; - async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; -} -``` - -- [ ] **Write `crates/domain/src/testing.rs`** — in-memory impls of all ports for use-case tests: - -```rust -use std::sync::{Arc, Mutex}; -use async_trait::async_trait; -use chrono::Utc; -use crate::{ - errors::DomainError, events::DomainEvent, - models::{api_key::ApiKey, feed::{FeedEntry, PageParams, Paginated, UserSummary}, notification::Notification, remote_actor::RemoteActor, social::{Block, Boost, Follow, FollowState, Like}, tag::Tag, thought::Thought, top_friend::TopFriend, user::User}, - ports::*, - value_objects::{ApiKeyId, BoostId, Content, Email, LikeId, NotificationId, PasswordHash, ThoughtId, UserId, Username}, -}; - -#[derive(Default, Clone)] -pub struct TestStore { - 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 events: Arc>>, -} - -#[async_trait] impl UserRepository for TestStore { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - Ok(self.users.lock().unwrap().iter().find(|u| &u.id == id).cloned()) - } - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - Ok(self.users.lock().unwrap().iter().find(|u| u.username.as_str() == username.as_str()).cloned()) - } - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - Ok(self.users.lock().unwrap().iter().find(|u| u.email.as_str() == email.as_str()).cloned()) - } - async fn save(&self, user: &User) -> Result<(), DomainError> { - let mut g = self.users.lock().unwrap(); - g.retain(|u| u.id != user.id); - g.push(user.clone()); Ok(()) - } - async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { - if let Some(u) = self.users.lock().unwrap().iter_mut().find(|u| &u.id == user_id) { - u.display_name = display_name; u.bio = bio; u.avatar_url = avatar_url; u.header_url = header_url; u.custom_css = custom_css; - } - Ok(()) - } - async fn list_with_stats(&self) -> Result, DomainError> { Ok(vec![]) } -} - -#[async_trait] impl ThoughtRepository for TestStore { - async fn save(&self, t: &Thought) -> Result<(), DomainError> { - let mut g = self.thoughts.lock().unwrap(); g.retain(|x| x.id != t.id); g.push(t.clone()); Ok(()) - } - async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { - Ok(self.thoughts.lock().unwrap().iter().find(|t| &t.id == id).cloned()) - } - async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { - let mut g = self.thoughts.lock().unwrap(); - let before = g.len(); - g.retain(|t| !(&t.id == id && &t.user_id == user_id)); - if g.len() == before { return Err(DomainError::NotFound); } - Ok(()) - } - async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> { - if let Some(t) = self.thoughts.lock().unwrap().iter_mut().find(|t| &t.id == id) { - t.content = content.clone(); t.updated_at = Some(Utc::now()); - } - Ok(()) - } - async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { - Ok(self.thoughts.lock().unwrap().iter().filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id).cloned().collect()) - } - async fn list_by_user(&self, user_id: &UserId, _page: &PageParams) -> Result, DomainError> { - let _ = user_id; Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } -} - -#[async_trait] impl LikeRepository for TestStore { - async fn save(&self, like: &Like) -> Result<(), DomainError> { - let mut g = self.likes.lock().unwrap(); - if g.iter().any(|l| l.user_id == like.user_id && l.thought_id == like.thought_id) { return Err(DomainError::Conflict("already liked".into())); } - g.push(like.clone()); Ok(()) - } - async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let mut g = self.likes.lock().unwrap(); - let before = g.len(); g.retain(|l| !(&l.user_id == user_id && &l.thought_id == thought_id)); - if g.len() == before { return Err(DomainError::NotFound); } Ok(()) - } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { - Ok(self.likes.lock().unwrap().iter().find(|l| &l.user_id == user_id && &l.thought_id == thought_id).cloned()) - } - async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { - Ok(self.likes.lock().unwrap().iter().filter(|l| &l.thought_id == thought_id).count() as i64) - } -} - -#[async_trait] impl BoostRepository for TestStore { - async fn save(&self, boost: &Boost) -> Result<(), DomainError> { - let mut g = self.boosts.lock().unwrap(); - if g.iter().any(|b| b.user_id == boost.user_id && b.thought_id == boost.thought_id) { return Err(DomainError::Conflict("already boosted".into())); } - g.push(boost.clone()); Ok(()) - } - async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let mut g = self.boosts.lock().unwrap(); - let before = g.len(); g.retain(|b| !(&b.user_id == user_id && &b.thought_id == thought_id)); - if g.len() == before { return Err(DomainError::NotFound); } Ok(()) - } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { - Ok(self.boosts.lock().unwrap().iter().find(|b| &b.user_id == user_id && &b.thought_id == thought_id).cloned()) - } - async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { - Ok(self.boosts.lock().unwrap().iter().filter(|b| &b.thought_id == thought_id).count() as i64) - } -} - -#[async_trait] impl FollowRepository for TestStore { - async fn save(&self, follow: &Follow) -> Result<(), DomainError> { - let mut g = self.follows.lock().unwrap(); - g.retain(|f| !(f.follower_id == follow.follower_id && f.following_id == follow.following_id)); - g.push(follow.clone()); Ok(()) - } - async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - let mut g = self.follows.lock().unwrap(); - let before = g.len(); g.retain(|f| !(&f.follower_id == follower_id && &f.following_id == following_id)); - if g.len() == before { return Err(DomainError::NotFound); } Ok(()) - } - async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError> { - Ok(self.follows.lock().unwrap().iter().find(|f| &f.follower_id == follower_id && &f.following_id == following_id).cloned()) - } - async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { - if let Some(f) = self.follows.lock().unwrap().iter_mut().find(|f| &f.follower_id == follower_id && &f.following_id == following_id) { f.state = state.clone(); } - Ok(()) - } - async fn list_followers(&self, user_id: &UserId, _p: &PageParams) -> Result, DomainError> { - let _ = user_id; Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn list_following(&self, user_id: &UserId, _p: &PageParams) -> Result, DomainError> { - let _ = user_id; Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError> { - Ok(self.follows.lock().unwrap().iter().filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted).map(|f| f.following_id.clone()).collect()) - } -} - -#[async_trait] impl BlockRepository for TestStore { - async fn save(&self, block: &Block) -> Result<(), DomainError> { self.blocks.lock().unwrap().push(block.clone()); Ok(()) } - async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - self.blocks.lock().unwrap().retain(|b| !(&b.blocker_id == blocker_id && &b.blocked_id == blocked_id)); Ok(()) - } - async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { - Ok(self.blocks.lock().unwrap().iter().any(|b| &b.blocker_id == blocker_id && &b.blocked_id == blocked_id)) - } -} - -#[async_trait] impl TagRepository for TestStore { - async fn find_or_create(&self, name: &str) -> Result { - let mut g = self.tags.lock().unwrap(); - if let Some(t) = g.iter().find(|t| t.name == name) { return Ok(t.clone()); } - let tag = Tag { id: g.len() as i32 + 1, name: name.to_string() }; - g.push(tag.clone()); Ok(tag) - } - async fn attach_to_thought(&self, _tid: &ThoughtId, _tag_id: i32) -> Result<(), DomainError> { Ok(()) } - async fn detach_from_thought(&self, _tid: &ThoughtId) -> Result<(), DomainError> { Ok(()) } - async fn list_for_thought(&self, _tid: &ThoughtId) -> Result, DomainError> { Ok(vec![]) } - async fn list_thoughts_by_tag(&self, _name: &str, _p: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } -} - -#[async_trait] impl ApiKeyRepository for TestStore { - async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { self.api_keys.lock().unwrap().push(key.clone()); Ok(()) } - async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { - Ok(self.api_keys.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) - } - async fn list_for_user(&self, uid: &UserId) -> Result, DomainError> { - Ok(self.api_keys.lock().unwrap().iter().filter(|k| &k.user_id == uid).cloned().collect()) - } - async fn delete(&self, id: &ApiKeyId, uid: &UserId) -> Result<(), DomainError> { - self.api_keys.lock().unwrap().retain(|k| !(&k.id == id && &k.user_id == uid)); Ok(()) - } -} - -#[async_trait] impl TopFriendRepository for TestStore { - async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { - let mut g = self.top_friends.lock().unwrap(); - g.retain(|tf| &tf.user_id != user_id); - for (fid, pos) in friends { g.push(TopFriend { user_id: user_id.clone(), friend_id: fid, position: pos }); } - Ok(()) - } - async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { Ok(vec![]) } -} - -#[async_trait] impl NotificationRepository for TestStore { - async fn save(&self, n: &Notification) -> Result<(), DomainError> { self.notifications.lock().unwrap().push(n.clone()); Ok(()) } - async fn list_for_user(&self, uid: &UserId, _p: &PageParams) -> Result, DomainError> { - let items: Vec<_> = self.notifications.lock().unwrap().iter().filter(|n| &n.user_id == uid).cloned().collect(); - let total = items.len() as i64; - Ok(Paginated { items, total, page: 1, per_page: 20 }) - } - async fn mark_read(&self, id: &NotificationId, _uid: &UserId) -> Result<(), DomainError> { - if let Some(n) = self.notifications.lock().unwrap().iter_mut().find(|n| &n.id == id) { n.read = true; } - Ok(()) - } - async fn mark_all_read(&self, uid: &UserId) -> Result<(), DomainError> { - for n in self.notifications.lock().unwrap().iter_mut().filter(|n| &n.user_id == uid) { n.read = true; } - Ok(()) - } -} - -#[async_trait] impl RemoteActorRepository for TestStore { - async fn upsert(&self, _a: &RemoteActor) -> Result<(), DomainError> { Ok(()) } - async fn find_by_url(&self, _url: &str) -> Result, DomainError> { Ok(None) } -} - -#[async_trait] impl FeedRepository for TestStore { - async fn home_feed(&self, _ids: &[UserId], _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn public_feed(&self, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn search(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } -} - -#[async_trait] impl EventPublisher for TestStore { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - self.events.lock().unwrap().push(event.clone()); Ok(()) - } -} - -pub struct NoOpEventPublisher; -#[async_trait] impl EventPublisher for NoOpEventPublisher { - async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } -} -``` - -- [ ] **Run:** `cargo check -p domain` — Expected: no errors. - -- [ ] **Commit:** -```bash -git add crates/domain/ -git commit -m "feat(domain): ports, events, test helpers" -``` - ---- - -### Task 5: Postgres — migrations - -**Files:** `crates/adapters/postgres/migrations/001_initial_schema.sql`, `002_federation_columns.sql`, `003_new_tables.sql` - -- [ ] **Write `migrations/001_initial_schema.sql`** (recreates production schema exactly): - -```sql -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(32) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - display_name VARCHAR(50), - bio VARCHAR(160), - avatar_url TEXT, - header_url TEXT, - custom_css TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS thoughts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content VARCHAR(128) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS follows ( - follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (follower_id, following_id) -); - -CREATE TABLE IF NOT EXISTS top_friends ( - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - friend_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - position SMALLINT NOT NULL, - PRIMARY KEY (user_id, friend_id), - UNIQUE (user_id, position) -); - -CREATE TABLE IF NOT EXISTS tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE -); - -CREATE TABLE IF NOT EXISTS thought_tags ( - thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (thought_id, tag_id) -); - -CREATE TABLE IF NOT EXISTS api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - key_hash TEXT NOT NULL UNIQUE, - name VARCHAR(50) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -``` - -- [ ] **Write `migrations/002_federation_columns.sql`:** - -```sql -ALTER TABLE users - ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE, - ADD COLUMN IF NOT EXISTS inbox_url TEXT, - ADD COLUMN IF NOT EXISTS public_key TEXT, - ADD COLUMN IF NOT EXISTS private_key TEXT, - ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true; - -ALTER TABLE thoughts - ADD COLUMN IF NOT EXISTS in_reply_to_id UUID REFERENCES thoughts(id), - ADD COLUMN IF NOT EXISTS in_reply_to_url TEXT, - ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE, - ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'public', - ADD COLUMN IF NOT EXISTS content_warning TEXT, - ADD COLUMN IF NOT EXISTS sensitive BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ; - -ALTER TABLE follows - ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'accepted', - ADD COLUMN IF NOT EXISTS ap_id TEXT, - ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); -``` - -- [ ] **Write `migrations/003_new_tables.sql`:** - -```sql -CREATE TABLE IF NOT EXISTS likes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE, - ap_id TEXT UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, thought_id) -); - -CREATE TABLE IF NOT EXISTS boosts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE, - ap_id TEXT UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, thought_id) -); - -CREATE TABLE IF NOT EXISTS blocks ( - blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (blocker_id, blocked_id) -); - -CREATE TABLE IF NOT EXISTS remote_actors ( - url TEXT PRIMARY KEY, - handle TEXT NOT NULL, - display_name TEXT, - inbox_url TEXT NOT NULL, - shared_inbox_url TEXT, - public_key TEXT NOT NULL, - last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, - from_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - thought_id UUID REFERENCES thoughts(id) ON DELETE CASCADE, - read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_thoughts_user_id ON thoughts(user_id); -CREATE INDEX IF NOT EXISTS idx_thoughts_created_at ON thoughts(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows(following_id); -CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, read); -``` - -- [ ] **Start test database:** `docker compose up -d postgres` (or equivalent). - -- [ ] **Run migrations against test DB** to verify SQL is valid: -```bash -export DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts_test -sqlx database create -sqlx migrate run --source crates/adapters/postgres/migrations -``` -Expected: `Applied 3 migrations` - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/migrations/ -git commit -m "feat(postgres): initial migrations" -``` - ---- - -### Task 6: Postgres — UserRepository - -**Files:** `crates/adapters/postgres/src/lib.rs`, `crates/adapters/postgres/src/user.rs` - -- [ ] **Write the integration test** (bottom of `user.rs`): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::value_objects::*; - use domain::models::user::User; - - #[sqlx::test(migrations = "migrations")] - async fn save_and_find_by_id(pool: sqlx::PgPool) { - let repo = PgUserRepository::new(pool); - let user = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("hash".into())); - repo.save(&user).await.unwrap(); - let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); - assert_eq!(found.username.as_str(), "alice"); - } - - #[sqlx::test(migrations = "migrations")] - async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { - let repo = PgUserRepository::new(pool); - let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap(); - assert!(result.is_none()); - } -} -``` - -- [ ] **Run:** `cargo test -p postgres save_and_find` — Expected: FAIL (no impl). - -- [ ] **Write `crates/adapters/postgres/src/lib.rs`:** - -```rust -pub mod api_key; -pub mod block; -pub mod boost; -pub mod feed; -pub mod follow; -pub mod like; -pub mod notification; -pub mod remote_actor; -pub mod tag; -pub mod thought; -pub mod top_friend; -pub mod user; -``` - -- [ ] **Write `crates/adapters/postgres/src/user.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{ - errors::DomainError, - models::{feed::UserSummary, user::User}, - ports::UserRepository, - value_objects::{Email, PasswordHash, UserId, Username}, -}; - -pub struct PgUserRepository { pool: PgPool } -impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[derive(sqlx::FromRow)] -struct UserRow { - id: uuid::Uuid, username: String, email: String, password_hash: String, - display_name: Option, bio: Option, - avatar_url: Option, header_url: Option, custom_css: Option, - local: bool, ap_id: Option, inbox_url: Option, - public_key: Option, private_key: Option, - created_at: DateTime, updated_at: DateTime, -} - -impl From for User { - fn from(r: UserRow) -> Self { - User { - id: UserId::from_uuid(r.id), username: Username::from_trusted(r.username), - email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, - avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, - public_key: r.public_key, private_key: r.private_key, - created_at: r.created_at, updated_at: r.updated_at, - } - } -} - -#[async_trait] -impl UserRepository for PgUserRepository { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - sqlx::query_as::<_, UserRow>( - "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1" - ).bind(id.as_uuid()).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(User::from)) - } - - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - sqlx::query_as::<_, UserRow>( - "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE username=$1" - ).bind(username.as_str()).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(User::from)) - } - - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - sqlx::query_as::<_, UserRow>( - "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE email=$1" - ).bind(email.as_str()).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(User::from)) - } - - async fn save(&self, user: &User) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) - ON CONFLICT(id) DO UPDATE SET username=EXCLUDED.username,email=EXCLUDED.email,password_hash=EXCLUDED.password_hash,display_name=EXCLUDED.display_name,bio=EXCLUDED.bio,avatar_url=EXCLUDED.avatar_url,header_url=EXCLUDED.header_url,custom_css=EXCLUDED.custom_css,local=EXCLUDED.local,ap_id=EXCLUDED.ap_id,inbox_url=EXCLUDED.inbox_url,public_key=EXCLUDED.public_key,private_key=EXCLUDED.private_key,updated_at=NOW()" - ) - .bind(user.id.as_uuid()).bind(user.username.as_str()).bind(user.email.as_str()) - .bind(&user.password_hash.0).bind(&user.display_name).bind(&user.bio) - .bind(&user.avatar_url).bind(&user.header_url).bind(&user.custom_css) - .bind(user.local).bind(&user.ap_id).bind(&user.inbox_url) - .bind(&user.public_key).bind(&user.private_key) - .bind(user.created_at).bind(user.updated_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { - sqlx::query("UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1") - .bind(user_id.as_uuid()).bind(display_name).bind(bio).bind(avatar_url).bind(header_url).bind(custom_css) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn list_with_stats(&self) -> Result, DomainError> { - #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, username: String, display_name: Option, avatar_url: Option, bio: Option, thought_count: i64, follower_count: i64, following_count: i64 } - sqlx::query_as::<_, Row>( - "SELECT u.id,u.username,u.display_name,u.avatar_url,u.bio, - COUNT(DISTINCT t.id) AS thought_count, - COUNT(DISTINCT f1.follower_id) AS follower_count, - COUNT(DISTINCT f2.following_id) AS following_count - FROM users u - LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true - LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted' - LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted' - WHERE u.local=true GROUP BY u.id ORDER BY u.username" - ).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) - .map(|rows| rows.into_iter().map(|r| UserSummary { - id: UserId::from_uuid(r.id), username: r.username, - display_name: r.display_name, avatar_url: r.avatar_url, bio: r.bio, - thought_count: r.thought_count, follower_count: r.follower_count, following_count: r.following_count, - }).collect()) - } -} -``` - -- [ ] **Run:** `cargo test -p postgres` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/ -git commit -m "feat(postgres): UserRepository" -``` - ---- - -### Task 7: Postgres — ThoughtRepository - -**Files:** `crates/adapters/postgres/src/thought.rs` - -- [ ] **Write the test:** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::value_objects::*; - use domain::models::{thought::{Thought, Visibility}, user::User}; - use domain::models::feed::PageParams; - use crate::user::PgUserRepository; - use domain::ports::UserRepository; - - async fn seed_user(pool: &sqlx::PgPool) -> User { - let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into())); - repo.save(&u).await.unwrap(); u - } - - #[sqlx::test(migrations = "migrations")] - async fn save_and_find_thought(pool: sqlx::PgPool) { - let user = seed_user(&pool).await; - let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("hello").unwrap(), None, Visibility::Public, None, false); - repo.save(&t).await.unwrap(); - let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); - assert_eq!(found.content.as_str(), "hello"); - } - - #[sqlx::test(migrations = "migrations")] - async fn delete_thought(pool: sqlx::PgPool) { - let user = seed_user(&pool).await; - let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("bye").unwrap(), None, Visibility::Public, None, false); - repo.save(&t).await.unwrap(); - repo.delete(&t.id, &user.id).await.unwrap(); - assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); - } -} -``` - -- [ ] **Run:** `cargo test -p postgres thought` — Expected: FAIL. - -- [ ] **Write `crates/adapters/postgres/src/thought.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{ - errors::DomainError, - models::{feed::{FeedEntry, PageParams, Paginated}, thought::{Thought, Visibility}, user::User}, - ports::ThoughtRepository, - value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, -}; - -pub struct PgThoughtRepository { pool: PgPool } -impl PgThoughtRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[derive(sqlx::FromRow)] -struct ThoughtRow { - id: uuid::Uuid, user_id: uuid::Uuid, content: String, - in_reply_to_id: Option, in_reply_to_url: Option, - ap_id: Option, visibility: String, content_warning: Option, - sensitive: bool, local: bool, created_at: DateTime, updated_at: Option>, -} -impl From for Thought { - fn from(r: ThoughtRow) -> Self { - Thought { - id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), - content: Content::new_remote(r.content), - in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), - in_reply_to_url: r.in_reply_to_url, ap_id: r.ap_id, - visibility: Visibility::from_str(&r.visibility), - content_warning: r.content_warning, sensitive: r.sensitive, local: r.local, - created_at: r.created_at, updated_at: r.updated_at, - } - } -} - -#[async_trait] -impl ThoughtRepository for PgThoughtRepository { - async fn save(&self, t: &Thought) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO thoughts(id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()" - ) - .bind(t.id.as_uuid()).bind(t.user_id.as_uuid()).bind(t.content.as_str()) - .bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid())) - .bind(&t.in_reply_to_url).bind(&t.ap_id).bind(t.visibility.as_str()) - .bind(&t.content_warning).bind(t.sensitive).bind(t.local).bind(t.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { - sqlx::query_as::<_, ThoughtRow>( - "SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts WHERE id=$1" - ).bind(id.as_uuid()).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())).map(|o| o.map(Thought::from)) - } - - async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { - let r = sqlx::query("DELETE FROM thoughts WHERE id=$1 AND user_id=$2") - .bind(id.as_uuid()).bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } - Ok(()) - } - - async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> { - sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE id=$1") - .bind(id.as_uuid()).bind(content.as_str()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { - sqlx::query_as::<_, ThoughtRow>( - "SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at - FROM thoughts WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC" - ).bind(id.as_uuid()).fetch_all(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|rows| rows.into_iter().map(Thought::from).collect()) - } - - async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id=$1") - .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let rows = sqlx::query_as::<_, ThoughtRow>( - "SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at - FROM thoughts WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" - ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()) - .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - // Note: FeedEntry requires author User — feed.rs handles joined queries; here we build minimal entries - // This method is used for profile pages where the author is already known - let thought_ids: Vec<_> = rows.iter().map(|r| r.id).collect(); - let user_row = sqlx::query_as::<_, crate::user::UserRow>( - "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1" - ).bind(user_id.as_uuid()).fetch_optional(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))? - .ok_or(DomainError::NotFound)?; - let author = User::from(user_row); - let items = rows.into_iter().map(|r| { - let thought = Thought::from(r); - FeedEntry { thought, author: author.clone(), like_count: 0, boost_count: 0, reply_count: 0, liked_by_viewer: false, boosted_by_viewer: false } - }).collect(); - Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) - } -} -``` - -Note: `crate::user::UserRow` needs to be `pub`. Update `user.rs`: change `struct UserRow` to `pub(crate) struct UserRow` and add `impl From for User` as `pub(crate)`. - -- [ ] **Run:** `cargo test -p postgres thought` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/src/thought.rs crates/adapters/postgres/src/user.rs -git commit -m "feat(postgres): ThoughtRepository" -``` - ---- - -### Task 8: Postgres — FollowRepository + BlockRepository - -**Files:** `crates/adapters/postgres/src/follow.rs`, `crates/adapters/postgres/src/block.rs` - -- [ ] **Write the tests:** - -```rust -// follow.rs tests -#[sqlx::test(migrations = "migrations")] -async fn follow_and_find(pool: sqlx::PgPool) { - // seed two users first (copy seed_user pattern from Task 7, use "alice"/"bob") - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgFollowRepository::new(pool); - let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: chrono::Utc::now() }; - repo.save(&follow).await.unwrap(); - let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); - assert_eq!(found.state, FollowState::Accepted); -} - -// block.rs tests -#[sqlx::test(migrations = "migrations")] -async fn block_exists(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgBlockRepository::new(pool); - let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: chrono::Utc::now() }; - repo.save(&block).await.unwrap(); - assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); - assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); -} -``` - -- [ ] **Run:** `cargo test -p postgres follow block` — Expected: FAIL. - -- [ ] **Write `crates/adapters/postgres/src/follow.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, social::{Follow, FollowState}, user::User}, ports::FollowRepository, value_objects::UserId}; - -pub struct PgFollowRepository { pool: PgPool } -impl PgFollowRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl FollowRepository for PgFollowRepository { - async fn save(&self, f: &Follow) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO follows(follower_id,following_id,state,ap_id,created_at) VALUES($1,$2,$3,$4,$5) - ON CONFLICT(follower_id,following_id) DO UPDATE SET state=EXCLUDED.state,ap_id=EXCLUDED.ap_id" - ).bind(f.follower_id.as_uuid()).bind(f.following_id.as_uuid()).bind(f.state.as_str()).bind(&f.ap_id).bind(f.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - let r = sqlx::query("DELETE FROM follows WHERE follower_id=$1 AND following_id=$2") - .bind(follower_id.as_uuid()).bind(following_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } - Ok(()) - } - - async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] - struct Row { follower_id: uuid::Uuid, following_id: uuid::Uuid, state: String, ap_id: Option, created_at: DateTime } - sqlx::query_as::<_, Row>("SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2") - .bind(follower_id.as_uuid()).bind(following_id.as_uuid()).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(|r| Follow { follower_id: UserId::from_uuid(r.follower_id), following_id: UserId::from_uuid(r.following_id), state: FollowState::from_str(&r.state), ap_id: r.ap_id, created_at: r.created_at })) - } - - async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { - sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2") - .bind(follower_id.as_uuid()).bind(following_id.as_uuid()).bind(state.as_str()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - - async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'") - .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let rows = sqlx::query_as::<_, crate::user::UserRow>( - "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at - FROM users u JOIN follows f ON f.follower_id=u.id WHERE f.following_id=$1 AND f.state='accepted' ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" - ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(User::from).collect(), total, page: page.page, per_page: page.per_page }) - } - - async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'") - .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let rows = sqlx::query_as::<_, crate::user::UserRow>( - "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at - FROM users u JOIN follows f ON f.following_id=u.id WHERE f.follower_id=$1 AND f.state='accepted' ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" - ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(User::from).collect(), total, page: page.page, per_page: page.per_page }) - } - - async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError> { - let ids: Vec = sqlx::query_scalar("SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'") - .bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(ids.into_iter().map(UserId::from_uuid).collect()) - } -} -``` - -- [ ] **Write `crates/adapters/postgres/src/block.rs`:** - -```rust -use async_trait::async_trait; -use chrono::Utc; -use sqlx::PgPool; -use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId}; - -pub struct PgBlockRepository { pool: PgPool } -impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl BlockRepository for PgBlockRepository { - async fn save(&self, b: &Block) -> Result<(), DomainError> { - sqlx::query("INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING") - .bind(b.blocker_id.as_uuid()).bind(b.blocked_id.as_uuid()).bind(b.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") - .bind(blocker_id.as_uuid()).bind(blocked_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") - .bind(blocker_id.as_uuid()).bind(blocked_id.as_uuid()) - .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(count > 0) - } -} -``` - -- [ ] **Run:** `cargo test -p postgres` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/src/follow.rs crates/adapters/postgres/src/block.rs -git commit -m "feat(postgres): FollowRepository, BlockRepository" -``` - ---- - -### Task 9: Postgres — LikeRepository + BoostRepository - -**Files:** `crates/adapters/postgres/src/like.rs`, `crates/adapters/postgres/src/boost.rs` - -- [ ] **Write tests** (same seed_user + seed_thought pattern): - -```rust -// like.rs -#[sqlx::test(migrations = "migrations")] -async fn like_and_count(pool: sqlx::PgPool) { - let (user, thought) = seed_user_and_thought(&pool).await; - let repo = PgLikeRepository::new(pool); - let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; - repo.save(&like).await.unwrap(); - assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); - repo.delete(&user.id, &thought.id).await.unwrap(); - assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); -} - -// boost.rs — identical structure, use PgBoostRepository -``` - -- [ ] **Run:** `cargo test -p postgres like boost` — Expected: FAIL. - -- [ ] **Write `crates/adapters/postgres/src/like.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{errors::DomainError, models::social::Like, ports::LikeRepository, value_objects::{LikeId, ThoughtId, UserId}}; - -pub struct PgLikeRepository { pool: PgPool } -impl PgLikeRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl LikeRepository for PgLikeRepository { - async fn save(&self, l: &Like) -> Result<(), DomainError> { - sqlx::query("INSERT INTO likes(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING") - .bind(l.id.as_uuid()).bind(l.user_id.as_uuid()).bind(l.thought_id.as_uuid()).bind(&l.ap_id).bind(l.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let r = sqlx::query("DELETE FROM likes WHERE user_id=$1 AND thought_id=$2") - .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } - Ok(()) - } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option, created_at: DateTime } - sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2") - .bind(user_id.as_uuid()).bind(thought_id.as_uuid()).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(|r| Like { id: LikeId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at })) - } - async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { - sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1") - .bind(thought_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) - } -} -``` - -- [ ] **Write `crates/adapters/postgres/src/boost.rs`** — identical structure, replace `likes` table with `boosts`, `LikeId`→`BoostId`, `PgLikeRepository`→`PgBoostRepository`, `LikeRepository`→`BoostRepository`, `Like`→`Boost`. - -- [ ] **Run:** `cargo test -p postgres` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/src/like.rs crates/adapters/postgres/src/boost.rs -git commit -m "feat(postgres): LikeRepository, BoostRepository" -``` - ---- - -### Task 10: Postgres — TagRepository, ApiKeyRepository, TopFriendRepository, NotificationRepository, RemoteActorRepository - -**Files:** `src/tag.rs`, `src/api_key.rs`, `src/top_friend.rs`, `src/notification.rs`, `src/remote_actor.rs` - -- [ ] **Write `src/tag.rs`:** - -```rust -use async_trait::async_trait; -use sqlx::PgPool; -use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, tag::Tag, thought::Thought}, ports::TagRepository, value_objects::ThoughtId}; - -pub struct PgTagRepository { pool: PgPool } -impl PgTagRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl TagRepository for PgTagRepository { - async fn find_or_create(&self, name: &str) -> Result { - let name = name.to_lowercase(); - sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING").bind(&name) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - #[derive(sqlx::FromRow)] struct Row { id: i32, name: String } - let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1").bind(&name) - .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Tag { id: row.id, name: row.name }) - } - async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError> { - sqlx::query("INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING") - .bind(thought_id.as_uuid()).bind(tag_id) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1").bind(thought_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { id: i32, name: String } - sqlx::query_as::<_, Row>("SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1") - .bind(thought_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) - .map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect()) - } - async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1") - .bind(tag_name).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>( - "SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at - FROM thoughts th JOIN thought_tags tt ON tt.thought_id=th.id JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1 ORDER BY th.created_at DESC LIMIT $2 OFFSET $3" - ).bind(tag_name).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(Thought::from).collect(), total, page: page.page, per_page: page.per_page }) - } -} -``` - -Make `ThoughtRow` `pub(crate)` in `thought.rs` (same as UserRow pattern). - -- [ ] **Write `src/api_key.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}}; - -pub struct PgApiKeyRepository { pool: PgPool } -impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl ApiKeyRepository for PgApiKeyRepository { - async fn save(&self, k: &ApiKey) -> Result<(), DomainError> { - sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)") - .bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime } - sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1").bind(hash) - .fetch_optional(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at })) - } - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime } - sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC").bind(user_id.as_uuid()) - .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) - .map(|rows| rows.into_iter().map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect()) - } - async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2").bind(id.as_uuid()).bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } -} -``` - -- [ ] **Write `src/top_friend.rs`:** - -```rust -use async_trait::async_trait; -use sqlx::PgPool; -use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::TopFriendRepository, value_objects::UserId}; - -pub struct PgTopFriendRepository { pool: PgPool } -impl PgTopFriendRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl TopFriendRepository for PgTopFriendRepository { - async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { - let mut tx = self.pool.begin().await.map_err(|e| DomainError::Internal(e.to_string()))?; - sqlx::query("DELETE FROM top_friends WHERE user_id=$1").bind(user_id.as_uuid()).execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; - for (friend_id, pos) in friends { - sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)") - .bind(user_id.as_uuid()).bind(friend_id.as_uuid()).bind(pos) - .execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; - } - tx.commit().await.map_err(|e| DomainError::Internal(e.to_string())) - } - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] - struct Row { user_id: uuid::Uuid, friend_id: uuid::Uuid, position: i16, id: uuid::Uuid, username: String, email: String, password_hash: String, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option, local: bool, ap_id: Option, inbox_url: Option, public_key: Option, private_key: Option, created_at: chrono::DateTime, updated_at: chrono::DateTime } - let rows = sqlx::query_as::<_, Row>( - "SELECT tf.user_id,tf.friend_id,tf.position,u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at - FROM top_friends tf JOIN users u ON u.id=tf.friend_id WHERE tf.user_id=$1 ORDER BY tf.position" - ).bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(rows.into_iter().map(|r| { - let tf = TopFriend { user_id: UserId::from_uuid(r.user_id), friend_id: UserId::from_uuid(r.friend_id), position: r.position }; - let u = crate::user::UserRow { id: r.id, username: r.username, email: r.email, password_hash: r.password_hash, display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, public_key: r.public_key, private_key: r.private_key, created_at: r.created_at, updated_at: r.updated_at }; - (tf, User::from(u)) - }).collect()) - } -} -``` - -- [ ] **Write `src/notification.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, notification::{Notification, NotificationType}}, ports::NotificationRepository, value_objects::{NotificationId, ThoughtId, UserId}}; - -pub struct PgNotificationRepository { pool: PgPool } -impl PgNotificationRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl NotificationRepository for PgNotificationRepository { - async fn save(&self, n: &Notification) -> Result<(), DomainError> { - sqlx::query("INSERT INTO notifications(id,user_id,type,from_user_id,thought_id,read,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)") - .bind(n.id.as_uuid()).bind(n.user_id.as_uuid()).bind(n.notification_type.as_str()) - .bind(n.from_user_id.as_ref().map(|u| u.as_uuid())).bind(n.thought_id.as_ref().map(|t| t.as_uuid())) - .bind(n.read).bind(n.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1").bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, r#type: String, from_user_id: Option, thought_id: Option, read: bool, created_at: DateTime } - let rows = sqlx::query_as::<_, Row>("SELECT id,user_id,type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3") - .bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let items = rows.into_iter().map(|r| Notification { id: NotificationId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), notification_type: NotificationType::from_str(&r.r#type), from_user_id: r.from_user_id.map(UserId::from_uuid), thought_id: r.thought_id.map(ThoughtId::from_uuid), read: r.read, created_at: r.created_at }).collect(); - Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) - } - async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> { - sqlx::query("UPDATE notifications SET read=true WHERE id=$1 AND user_id=$2").bind(id.as_uuid()).bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> { - sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1").bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } -} -``` - -- [ ] **Write `src/remote_actor.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository}; - -pub struct PgRemoteActorRepository { pool: PgPool } -impl PgRemoteActorRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[async_trait] -impl RemoteActorRepository for PgRemoteActorRepository { - async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at) VALUES($1,$2,$3,$4,$5,$6,$7) - 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,public_key=EXCLUDED.public_key,last_fetched_at=EXCLUDED.last_fetched_at" - ).bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.inbox_url).bind(&a.shared_inbox_url).bind(&a.public_key).bind(a.last_fetched_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) - } - async fn find_by_url(&self, url: &str) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { url: String, handle: String, display_name: Option, inbox_url: String, shared_inbox_url: Option, public_key: String, last_fetched_at: DateTime } - sqlx::query_as::<_, Row>("SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at FROM remote_actors WHERE url=$1").bind(url) - .fetch_optional(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, last_fetched_at: r.last_fetched_at })) - } -} -``` - -- [ ] **Write `src/feed.rs`** — home feed + public feed + basic ILIKE search: - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{errors::DomainError, models::{feed::{FeedEntry, PageParams, Paginated}, thought::Thought, user::User}, ports::FeedRepository, value_objects::UserId}; - -pub struct PgFeedRepository { pool: PgPool } -impl PgFeedRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -#[derive(sqlx::FromRow)] -struct FeedRow { - // thought fields - thought_id: uuid::Uuid, user_id: uuid::Uuid, content: String, - in_reply_to_id: Option, in_reply_to_url: Option, - ap_id: Option, visibility: String, content_warning: Option, - sensitive: bool, local: bool, thought_created_at: DateTime, updated_at: Option>, - // author fields - author_id: uuid::Uuid, username: String, email: String, password_hash: String, - display_name: Option, bio: Option, avatar_url: Option, - header_url: Option, custom_css: Option, author_local: bool, - author_ap_id: Option, inbox_url: Option, public_key: Option, - private_key: Option, author_created_at: DateTime, author_updated_at: DateTime, - // counts - like_count: i64, boost_count: i64, reply_count: i64, -} - -const FEED_SELECT: &str = " - SELECT t.id AS thought_id, t.user_id, t.content, t.in_reply_to_id, t.in_reply_to_url, - t.ap_id, t.visibility, t.content_warning, t.sensitive, t.local, - t.created_at AS thought_created_at, t.updated_at, - u.id AS author_id, u.username, u.email, u.password_hash, u.display_name, u.bio, - u.avatar_url, u.header_url, u.custom_css, u.local AS author_local, u.ap_id AS author_ap_id, - u.inbox_url, u.public_key, u.private_key, - u.created_at AS author_created_at, u.updated_at AS author_updated_at, - (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, - (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, - (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count - FROM thoughts t JOIN users u ON u.id=t.user_id"; - -fn row_to_entry(r: FeedRow, viewer_id: Option<&UserId>) -> FeedEntry { - use domain::models::thought::Visibility; - use domain::value_objects::{Content, Email, PasswordHash, ThoughtId, Username}; - let thought = Thought { - id: ThoughtId::from_uuid(r.thought_id), user_id: UserId::from_uuid(r.user_id), - content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), - in_reply_to_url: r.in_reply_to_url, ap_id: r.ap_id, - visibility: Visibility::from_str(&r.visibility), content_warning: r.content_warning, - sensitive: r.sensitive, local: r.local, created_at: r.thought_created_at, updated_at: r.updated_at, - }; - let author = User { - id: UserId::from_uuid(r.author_id), username: Username::from_trusted(r.username), - email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, header_url: r.header_url, - custom_css: r.custom_css, local: r.author_local, ap_id: r.author_ap_id, - inbox_url: r.inbox_url, public_key: r.public_key, private_key: r.private_key, - created_at: r.author_created_at, updated_at: r.author_updated_at, - }; - FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false } -} - -#[async_trait] -impl FeedRepository for PgFeedRepository { - async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { - let ids: Vec = following_ids.iter().map(|id| id.as_uuid()).collect(); - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'") - .bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let sql = format!("{FEED_SELECT} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"); - let rows = sqlx::query_as::<_, FeedRow>(&sql).bind(&ids).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(|r| row_to_entry(r, _viewer_id)).collect(), total, page: page.page, per_page: page.per_page }) - } - - async fn public_feed(&self, page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'") - .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let sql = format!("{FEED_SELECT} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2"); - let rows = sqlx::query_as::<_, FeedRow>(&sql).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(|r| row_to_entry(r, _viewer_id)).collect(), total, page: page.page, per_page: page.per_page }) - } - - async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { - let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")); - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts t WHERE t.content ILIKE $1 AND t.visibility='public'") - .bind(&pattern).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let sql = format!("{FEED_SELECT} WHERE t.content ILIKE $1 AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"); - let rows = sqlx::query_as::<_, FeedRow>(&sql).bind(&pattern).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(|r| row_to_entry(r, _viewer_id)).collect(), total, page: page.page, per_page: page.per_page }) - } -} -``` - -- [ ] **Run:** `cargo test -p postgres` — Expected: all PASS. - -- [ ] **Commit:** -```bash -git add crates/adapters/postgres/src/ -git commit -m "feat(postgres): Tag, ApiKey, TopFriend, Notification, RemoteActor, Feed repos" -``` - ---- - -### Task 11: Auth adapter - -**Files:** `crates/adapters/auth/src/lib.rs` - -- [ ] **Write the test:** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::{ports::AuthService, value_objects::UserId}; - - #[test] - fn generate_and_validate_token() { - let svc = JwtAuthService::new("secret".into(), 3600); - let id = UserId::new(); - let tok = svc.generate_token(&id).unwrap(); - let parsed = svc.validate_token(&tok.token).unwrap(); - assert_eq!(parsed.as_uuid(), id.as_uuid()); - } - - #[test] - fn invalid_token_returns_unauthorized() { - let svc = JwtAuthService::new("secret".into(), 3600); - assert!(svc.validate_token("not.a.token").is_err()); - } - - #[tokio::test] - async fn hash_and_verify() { - let hasher = Argon2PasswordHasher; - let hash = hasher.hash("mypassword").await.unwrap(); - assert!(hasher.verify("mypassword", &hash).await.unwrap()); - assert!(!hasher.verify("wrongpassword", &hash).await.unwrap()); - } -} -``` - -- [ ] **Run:** `cargo test -p auth` — Expected: FAIL. - -- [ ] **Write `crates/adapters/auth/src/lib.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{Duration, Utc}; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; -use domain::{ - errors::DomainError, - ports::{AuthService, GeneratedToken, PasswordHasher}, - value_objects::{PasswordHash, UserId}, -}; - -#[derive(Serialize, Deserialize)] -struct Claims { sub: String, exp: usize } - -pub struct JwtAuthService { secret: String, ttl_seconds: i64 } -impl JwtAuthService { - pub fn new(secret: String, ttl_seconds: i64) -> Self { Self { secret, ttl_seconds } } -} - -impl AuthService for JwtAuthService { - fn generate_token(&self, user_id: &UserId) -> Result { - let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize; - let claims = Claims { sub: user_id.as_uuid().to_string(), exp }; - let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(self.secret.as_bytes())) - .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(GeneratedToken { token, user_id: user_id.clone() }) - } - fn validate_token(&self, token: &str) -> Result { - let data = decode::(token, &DecodingKey::from_secret(self.secret.as_bytes()), &Validation::default()) - .map_err(|_| DomainError::Unauthorized)?; - let uuid = uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?; - Ok(UserId::from_uuid(uuid)) - } -} - -pub struct Argon2PasswordHasher; - -#[async_trait] -impl PasswordHasher for Argon2PasswordHasher { - async fn hash(&self, plain: &str) -> Result { - use argon2::{password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHasher as _}; - let salt = SaltString::generate(&mut OsRng); - let hash = Argon2::default().hash_password(plain.as_bytes(), &salt) - .map_err(|e| DomainError::Internal(e.to_string()))?.to_string(); - Ok(PasswordHash(hash)) - } - async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { - use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; - let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Argon2::default().verify_password(plain.as_bytes(), &parsed).is_ok()) - } -} -``` - -- [ ] **Run:** `cargo test -p auth` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/adapters/auth/ -git commit -m "feat(auth): JWT AuthService and Argon2 PasswordHasher" -``` - ---- - -### Task 12: api-types — request/response DTOs - -**Files:** `crates/api-types/src/lib.rs`, `src/requests.rs`, `src/responses.rs` - -- [ ] **Write `crates/api-types/src/requests.rs`:** - -```rust -use serde::Deserialize; -use uuid::Uuid; - -#[derive(Deserialize)] -pub struct RegisterRequest { pub username: String, pub email: String, pub password: String } - -#[derive(Deserialize)] -pub struct LoginRequest { pub email: String, pub password: String } - -#[derive(Deserialize)] -pub struct CreateThoughtRequest { - pub content: String, - pub in_reply_to_id: Option, - pub visibility: Option, - pub content_warning: Option, - pub sensitive: Option, -} - -#[derive(Deserialize)] -pub struct EditThoughtRequest { pub content: String } - -#[derive(Deserialize)] -pub struct UpdateProfileRequest { - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub header_url: Option, - pub custom_css: Option, -} - -#[derive(Deserialize)] -pub struct SetTopFriendsRequest { pub friend_ids: Vec } // ordered list, max 8 - -#[derive(Deserialize)] -pub struct CreateApiKeyRequest { pub name: String } - -#[derive(Deserialize)] -pub struct PaginationQuery { pub page: Option, pub per_page: Option } -impl PaginationQuery { - pub fn page(&self) -> u64 { self.page.unwrap_or(1).max(1) } - pub fn per_page(&self) -> u64 { self.per_page.unwrap_or(20).min(100) } -} - -#[derive(Deserialize)] -pub struct SearchQuery { pub q: String, pub page: Option, pub per_page: Option } -``` - -- [ ] **Write `crates/api-types/src/responses.rs`:** - -```rust -use chrono::{DateTime, Utc}; -use serde::Serialize; -use uuid::Uuid; - -#[derive(Serialize)] -pub struct AuthResponse { pub token: String, pub user: UserResponse } - -#[derive(Serialize, Clone)] -pub struct UserResponse { - pub id: Uuid, pub username: String, - pub display_name: Option, pub bio: Option, - pub avatar_url: Option, pub header_url: Option, - pub local: bool, pub created_at: DateTime, -} - -#[derive(Serialize, Clone)] -pub struct ThoughtResponse { - pub id: Uuid, pub content: String, pub author: UserResponse, - pub in_reply_to_id: Option, pub visibility: String, - pub content_warning: Option, pub sensitive: bool, - pub like_count: i64, pub boost_count: i64, pub reply_count: i64, - pub liked_by_viewer: bool, pub boosted_by_viewer: bool, - pub created_at: DateTime, pub updated_at: Option>, -} - -#[derive(Serialize)] -pub struct PagedResponse { - pub items: Vec, pub total: i64, pub page: u64, pub per_page: u64, -} - -#[derive(Serialize)] -pub struct ApiKeyResponse { pub id: Uuid, pub name: String, pub created_at: DateTime } - -#[derive(Serialize)] -pub struct NotificationResponse { - pub id: Uuid, pub notification_type: String, - pub from_user: Option, pub thought_id: Option, - pub read: bool, pub created_at: DateTime, -} - -#[derive(Serialize)] -pub struct ErrorResponse { pub error: String } -``` - -- [ ] **Update `crates/api-types/src/lib.rs`:** - -```rust -pub mod requests; -pub mod responses; -``` - -- [ ] **Run:** `cargo check -p api-types` — Expected: no errors. - -- [ ] **Commit:** -```bash -git add crates/api-types/ -git commit -m "feat(api-types): request and response DTOs" -``` - ---- - -### Task 13: Application — auth use cases - -**Files:** `crates/application/src/use_cases/auth.rs`, `src/use_cases/mod.rs`, `src/lib.rs` - -- [ ] **Write the test:** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::testing::{TestStore, NoOpEventPublisher}; - use domain::ports::{PasswordHasher, AuthService}; - - struct FakeHasher; - #[async_trait::async_trait] impl PasswordHasher for FakeHasher { - async fn hash(&self, plain: &str) -> Result { Ok(domain::value_objects::PasswordHash(plain.to_string())) } - async fn verify(&self, plain: &str, hash: &domain::value_objects::PasswordHash) -> Result { Ok(plain == hash.0) } - } - - struct FakeAuth; - impl AuthService for FakeAuth { - fn generate_token(&self, uid: &domain::value_objects::UserId) -> Result { Ok(domain::ports::GeneratedToken { token: uid.to_string(), user_id: uid.clone() }) } - fn validate_token(&self, token: &str) -> Result { Ok(domain::value_objects::UserId::from_uuid(uuid::Uuid::parse_str(token).map_err(|_| domain::errors::DomainError::Unauthorized)?)) } - } - - #[tokio::test] - async fn register_creates_user_and_returns_token() { - let store = TestStore::default(); - let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into() }).await.unwrap(); - assert_eq!(out.user.username.as_str(), "alice"); - assert!(!out.token.is_empty()); - } - - #[tokio::test] - async fn register_rejects_duplicate_username() { - let store = TestStore::default(); - let input = || RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into() }; - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); - let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap_err(); - assert!(matches!(err, domain::errors::DomainError::Conflict(_))); - } - - #[tokio::test] - async fn login_returns_token_on_valid_credentials() { - let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "bob".into(), email: "bob@ex.com".into(), password: "pw".into() }).await.unwrap(); - let out = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "bob@ex.com".into(), password: "pw".into() }).await.unwrap(); - assert!(!out.token.is_empty()); - } - - #[tokio::test] - async fn login_fails_on_wrong_password() { - let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "bob".into(), email: "bob@ex.com".into(), password: "pw".into() }).await.unwrap(); - let err = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "bob@ex.com".into(), password: "wrong".into() }).await.unwrap_err(); - assert!(matches!(err, domain::errors::DomainError::Unauthorized)); - } -} -``` - -- [ ] **Run:** `cargo test -p application auth` — Expected: FAIL. - -- [ ] **Write `crates/application/src/use_cases/auth.rs`:** - -```rust -use domain::{ - errors::DomainError, - models::user::User, - ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}, - value_objects::{Email, UserId, Username}, -}; - -pub struct RegisterInput { pub username: String, pub email: String, pub password: String } -pub struct RegisterOutput { pub user: User, pub token: String } - -pub async fn register(users: &dyn UserRepository, hasher: &dyn PasswordHasher, auth: &dyn AuthService, _events: &dyn EventPublisher, input: RegisterInput) -> Result { - let username = Username::new(input.username)?; - let email = Email::new(input.email)?; - if users.find_by_username(&username).await?.is_some() { return Err(DomainError::Conflict("username taken".into())); } - if users.find_by_email(&email).await?.is_some() { return Err(DomainError::Conflict("email taken".into())); } - let hash = hasher.hash(&input.password).await?; - let user = User::new_local(UserId::new(), username, email, hash); - users.save(&user).await?; - let token = auth.generate_token(&user.id)?; - Ok(RegisterOutput { user, token: token.token }) -} - -pub struct LoginInput { pub email: String, pub password: String } -pub struct LoginOutput { pub user: User, pub token: String } - -pub async fn login(users: &dyn UserRepository, hasher: &dyn PasswordHasher, auth: &dyn AuthService, input: LoginInput) -> Result { - let email = Email::new(input.email)?; - let user = users.find_by_email(&email).await?.ok_or(DomainError::Unauthorized)?; - if !hasher.verify(&input.password, &user.password_hash).await? { return Err(DomainError::Unauthorized); } - let token = auth.generate_token(&user.id)?; - Ok(LoginOutput { user, token: token.token }) -} -``` - -- [ ] **Run:** `cargo test -p application auth` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/application/ -git commit -m "feat(application): register and login use cases" -``` - ---- - -### Task 14: Application — thought use cases - -**Files:** `crates/application/src/use_cases/thoughts.rs` - -- [ ] **Write the test:** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::testing::{TestStore, NoOpEventPublisher}; - use domain::value_objects::*; - use domain::models::user::User; - - fn make_user() -> User { User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())) } - - #[tokio::test] - async fn create_thought_saves_and_publishes_event() { - let store = TestStore::default(); - store.save(&make_user()).await.unwrap(); // need user in store for FK - let user = { store.users.lock().unwrap().first().unwrap().clone() }; - let out = create_thought(&store, &store, &store, CreateThoughtInput { user_id: user.id.clone(), content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }).await.unwrap(); - assert_eq!(out.thought.content.as_str(), "hello"); - assert_eq!(store.events.lock().unwrap().len(), 1); - } - - #[tokio::test] - async fn delete_own_thought_succeeds() { - let store = TestStore::default(); - let user = make_user(); - store.users.lock().unwrap().push(user.clone()); - let out = create_thought(&store, &store, &store, CreateThoughtInput { user_id: user.id.clone(), content: "bye".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }).await.unwrap(); - delete_thought(&store, &store, &out.thought.id, &user.id).await.unwrap(); - assert!(store.thoughts.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn delete_other_thought_returns_forbidden() { - let store = TestStore::default(); - let alice = make_user(); - let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into())); - store.users.lock().unwrap().extend([alice.clone(), bob.clone()]); - let out = create_thought(&store, &store, &store, CreateThoughtInput { user_id: alice.id.clone(), content: "secret".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }).await.unwrap(); - let err = delete_thought(&store, &store, &out.thought.id, &bob.id).await.unwrap_err(); - assert!(matches!(err, domain::errors::DomainError::NotFound)); - } -} -``` - -- [ ] **Run:** `cargo test -p application thoughts` — Expected: FAIL. - -- [ ] **Write `crates/application/src/use_cases/thoughts.rs`:** - -```rust -use domain::{ - errors::DomainError, - events::DomainEvent, - models::thought::{Thought, Visibility}, - ports::{EventPublisher, ThoughtRepository, UserRepository}, - value_objects::{Content, ThoughtId, UserId}, -}; - -pub struct CreateThoughtInput { - pub user_id: UserId, pub content: String, - pub in_reply_to_id: Option, - pub visibility: Option, pub content_warning: Option, pub sensitive: bool, -} -pub struct CreateThoughtOutput { pub thought: Thought } - -pub async fn create_thought(thoughts: &dyn ThoughtRepository, _users: &dyn UserRepository, events: &dyn EventPublisher, input: CreateThoughtInput) -> Result { - let content = Content::new_local(input.content)?; - let visibility = input.visibility.as_deref().map(Visibility::from_str).unwrap_or(Visibility::Public); - let thought = Thought::new_local(ThoughtId::new(), input.user_id, content, input.in_reply_to_id.clone(), visibility, input.content_warning, input.sensitive); - thoughts.save(&thought).await?; - events.publish(&DomainEvent::ThoughtCreated { thought_id: thought.id.clone(), user_id: thought.user_id.clone(), in_reply_to_id: input.in_reply_to_id }).await?; - Ok(CreateThoughtOutput { thought }) -} - -pub async fn delete_thought(thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { - let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?; - if thought.user_id != *user_id { return Err(DomainError::NotFound); } // don't leak existence - thoughts.delete(id, user_id).await?; - events.publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone() }).await?; - Ok(()) -} - -pub async fn edit_thought(thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId, new_content: String) -> Result<(), DomainError> { - let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?; - if thought.user_id != *user_id { return Err(DomainError::NotFound); } - let content = Content::new_local(new_content)?; - thoughts.update_content(id, &content).await?; - events.publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone() }).await?; - Ok(()) -} - -pub async fn get_thought(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result { - thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound) -} - -pub async fn get_thread(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result, DomainError> { - thoughts.get_thread(id).await -} -``` - -- [ ] **Run:** `cargo test -p application thoughts` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/application/src/use_cases/thoughts.rs -git commit -m "feat(application): thought use cases" -``` - ---- - -### Task 15: Application — social use cases - -**Files:** `crates/application/src/use_cases/social.rs` - -- [ ] **Write the test:** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::testing::{TestStore, NoOpEventPublisher}; - use domain::value_objects::*; - use domain::models::user::User; - use domain::models::thought::{Thought, Visibility}; - - fn user(name: &str) -> User { User::new_local(UserId::new(), Username::new(name).unwrap(), Email::new(format!("{name}@ex.com")).unwrap(), PasswordHash("h".into())) } - - #[tokio::test] - async fn like_and_unlike() { - let store = TestStore::default(); - let alice = user("alice"); let thought_id = ThoughtId::new(); - store.thoughts.lock().unwrap().push(Thought::new_local(thought_id.clone(), alice.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false)); - like_thought(&store, &store, &alice.id, &thought_id).await.unwrap(); - assert_eq!(store.likes.lock().unwrap().len(), 1); - unlike_thought(&store, &store, &alice.id, &thought_id).await.unwrap(); - assert!(store.likes.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn follow_and_unfollow() { - let store = TestStore::default(); - let alice = user("alice"); let bob = user("bob"); - follow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); - assert!(store.follows.lock().unwrap().iter().any(|f| f.follower_id == alice.id && f.following_id == bob.id)); - unfollow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); - assert!(store.follows.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn cannot_follow_self() { - let store = TestStore::default(); - let alice = user("alice"); - let err = follow_user(&store, &store, &alice.id, &alice.id).await.unwrap_err(); - assert!(matches!(err, domain::errors::DomainError::InvalidInput(_))); - } -} -``` - -- [ ] **Run:** `cargo test -p application social` — Expected: FAIL. - -- [ ] **Write `crates/application/src/use_cases/social.rs`:** - -```rust -use chrono::Utc; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::social::{Block, Boost, Follow, FollowState, Like}, - ports::{BlockRepository, BoostRepository, EventPublisher, FollowRepository, LikeRepository, ThoughtRepository}, - value_objects::{BoostId, LikeId, ThoughtId, UserId}, -}; - -pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; - likes.save(&like).await?; - events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; - Ok(()) -} - -pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - likes.delete(user_id, thought_id).await?; - events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; - Ok(()) -} - -pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; - boosts.save(&boost).await?; - events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; - Ok(()) -} - -pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - boosts.delete(user_id, thought_id).await?; - events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; - Ok(()) -} - -pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); } - let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() }; - follows.save(&follow).await?; - events.publish(&DomainEvent::FollowRequested { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; - Ok(()) -} - -pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - follows.delete(follower_id, following_id).await?; - events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; - Ok(()) -} - -pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - follows.update_state(follower_id, following_id, &FollowState::Accepted).await?; - events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; - Ok(()) -} - -pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - follows.update_state(follower_id, following_id, &FollowState::Rejected).await?; - events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; - Ok(()) -} - -pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); } - let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() }; - blocks.save(&block).await?; - events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?; - Ok(()) -} - -pub async fn unblock_user(blocks: &dyn BlockRepository, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - blocks.delete(blocker_id, blocked_id).await?; - Ok(()) -} -``` - -- [ ] **Run:** `cargo test -p application social` — Expected: PASS. - -- [ ] **Commit:** -```bash -git add crates/application/src/use_cases/social.rs -git commit -m "feat(application): social use cases (like, boost, follow, block)" -``` - ---- - -### Task 16: Application — feed, profile, and api-key use cases - -**Files:** `use_cases/feed.rs`, `use_cases/profile.rs`, `use_cases/api_keys.rs`, `use_cases/mod.rs`, update `src/lib.rs` - -- [ ] **Write `use_cases/feed.rs`:** - -```rust -use domain::{errors::DomainError, models::feed::{FeedEntry, PageParams, Paginated, UserSummary}, ports::{FeedRepository, FollowRepository, TagRepository, ThoughtRepository, UserRepository}, value_objects::{ThoughtId, UserId}}; - -pub async fn get_home_feed(feed: &dyn FeedRepository, follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { - let following_ids = follows.get_accepted_following_ids(user_id).await?; - feed.home_feed(&following_ids, &page, Some(user_id)).await -} - -pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserId>, page: PageParams) -> Result, DomainError> { - feed.public_feed(&page, viewer_id).await -} - -pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { - thoughts.list_by_user(user_id, &page).await -} - -pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { - follows.list_followers(user_id, &page).await -} - -pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { - follows.list_following(user_id, &page).await -} - -pub async fn get_by_tag(tags: &dyn TagRepository, tag_name: &str, page: PageParams) -> Result, DomainError> { - tags.list_thoughts_by_tag(tag_name, &page).await -} - -pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { - feed.search(query, &page, viewer_id).await -} - -pub async fn get_profile(users: &dyn UserRepository) -> Result, DomainError> { - users.list_with_stats().await -} -``` - -- [ ] **Write `use_cases/profile.rs`:** - -```rust -use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::{TopFriendRepository, UserRepository}, value_objects::UserId}; - -pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result { - users.find_by_id(user_id).await?.ok_or(DomainError::NotFound) -} - -pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result { - let username = domain::value_objects::Username::from_trusted(username.to_string()); - users.find_by_username(&username).await?.ok_or(DomainError::NotFound) -} - -pub async fn update_profile(users: &dyn UserRepository, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { - users.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css).await -} - -pub async fn get_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId) -> Result, DomainError> { - top_friends.list_for_user(user_id).await -} - -pub async fn set_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId, friend_ids: Vec) -> Result<(), DomainError> { - if friend_ids.len() > 8 { return Err(DomainError::InvalidInput("top friends: max 8".into())); } - let friends: Vec<(UserId, i16)> = friend_ids.into_iter().enumerate().map(|(i, id)| (id, (i + 1) as i16)).collect(); - top_friends.set_top_friends(user_id, friends).await -} -``` - -- [ ] **Write `use_cases/api_keys.rs`:** - -```rust -use chrono::Utc; -use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}}; - -pub async fn list_api_keys(keys: &dyn ApiKeyRepository, user_id: &UserId) -> Result, DomainError> { - keys.list_for_user(user_id).await -} - -pub async fn create_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, name: String) -> Result<(ApiKey, String)> { - // Return the raw key once — after this it's only stored as hash - let raw_key = uuid::Uuid::new_v4().to_string().replace('-', ""); - let key_hash = sha256(&raw_key); - let key = ApiKey { id: ApiKeyId::new(), user_id: user_id.clone(), key_hash, name, created_at: Utc::now() }; - keys.save(&key).await?; - Ok((key, raw_key)) -} - -pub async fn delete_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, key_id: &ApiKeyId) -> Result<(), DomainError> { - keys.delete(key_id, user_id).await -} - -fn sha256(s: &str) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - // Use SHA-256 properly — add `sha2` to auth Cargo.toml or compute here with ring/sha2 - // Simplest: use sha2 crate - // For now stub — replace with real sha2 in implementation: - let mut h = DefaultHasher::new(); s.hash(&mut h); format!("{:x}", h.finish()) -} -``` - -Note: replace the `sha256` stub with real SHA-256 using the `sha2` crate. Add `sha2 = "0.10"` to `presentation/Cargo.toml` (or a dedicated hashing util). The api_keys use case should use `sha2::Sha256` and `hex` encoding. - -- [ ] **Write `use_cases/mod.rs`:** - -```rust -pub mod api_keys; -pub mod auth; -pub mod feed; -pub mod profile; -pub mod social; -pub mod thoughts; -``` - -- [ ] **Update `crates/application/src/lib.rs`:** - -```rust -pub mod use_cases; -``` - -- [ ] **Run:** `cargo check -p application` — Expected: no errors. - -- [ ] **Commit:** -```bash -git add crates/application/ -git commit -m "feat(application): feed, profile, api-key use cases" -``` - ---- - -### Task 17: Presentation — state, extractors, errors - -**Files:** `crates/presentation/src/state.rs`, `src/extractors.rs`, `src/errors.rs` - -- [ ] **Write `crates/presentation/src/state.rs`:** - -```rust -use std::sync::Arc; -use domain::ports::*; - -#[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 auth: Arc, - pub hasher: Arc, - pub events: Arc, -} -``` - -- [ ] **Write `crates/presentation/src/errors.rs`:** - -```rust -use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; -use domain::errors::DomainError; -use api_types::responses::ErrorResponse; - -pub enum ApiError { - Domain(DomainError), - Unauthorized, - BadRequest(String), -} - -impl From for ApiError { - fn from(e: DomainError) -> Self { Self::Domain(e) } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let (status, msg) = match self { - Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()), - Self::Domain(DomainError::Unauthorized) => (StatusCode::UNAUTHORIZED, "unauthorized".into()), - Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()), - Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m), - Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m), - Self::Domain(DomainError::Internal(m)) => (StatusCode::INTERNAL_SERVER_ERROR, m), - Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), - Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m), - }; - (status, Json(ErrorResponse { error: msg })).into_response() - } -} -``` - -- [ ] **Write `crates/presentation/src/extractors.rs`:** - -```rust -use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; -use domain::value_objects::UserId; -use crate::{errors::ApiError, state::AppState}; - -pub struct AuthUser(pub UserId); -pub struct OptionalAuthUser(pub Option); - -#[async_trait] -impl FromRequestParts for AuthUser { - type Rejection = ApiError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { - let uid = extract_user_id(parts, state).await?.ok_or(ApiError::Unauthorized)?; - Ok(AuthUser(uid)) - } -} - -#[async_trait] -impl FromRequestParts for OptionalAuthUser { - type Rejection = ApiError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { - Ok(OptionalAuthUser(extract_user_id(parts, state).await?)) - } -} - -async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result, ApiError> { - // Try Bearer token first - if let Some(auth_header) = parts.headers.get("Authorization") { - if let Ok(s) = auth_header.to_str() { - if let Some(token) = s.strip_prefix("Bearer ") { - return state.auth.validate_token(token).map(Some).map_err(|_| ApiError::Unauthorized); - } - } - } - // Try X-Api-Key header - if let Some(key_header) = parts.headers.get("X-Api-Key") { - if let Ok(raw) = key_header.to_str() { - let hash = sha256_hex(raw); - if let Ok(Some(key)) = state.api_keys.find_by_hash(&hash).await { - return Ok(Some(key.user_id)); - } - } - } - Ok(None) -} - -fn sha256_hex(s: &str) -> String { - use sha2::{Sha256, Digest}; - let hash = Sha256::digest(s.as_bytes()); - hex::encode(hash) -} -``` - -Add `sha2 = "0.10"` and `hex = "0.4"` to `crates/presentation/Cargo.toml` dependencies. - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors (handlers not wired yet). - -- [ ] **Commit:** -```bash -git add crates/presentation/src/state.rs crates/presentation/src/errors.rs crates/presentation/src/extractors.rs -git commit -m "feat(presentation): state, errors, extractors" -``` - ---- - -### Task 18: Presentation — auth and user handlers - -**Files:** `src/handlers/auth.rs`, `src/handlers/users.rs`, `src/handlers/mod.rs` - -- [ ] **Write `src/handlers/mod.rs`:** - -```rust -pub mod api_keys; -pub mod auth; -pub mod feed; -pub mod notifications; -pub mod social; -pub mod thoughts; -pub mod users; -``` - -- [ ] **Write `src/handlers/auth.rs`:** - -```rust -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, UserResponse}}; -use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; -use crate::{errors::ApiError, state::AppState}; - -pub async fn post_register(State(s): State, Json(body): Json) -> Result { - let out = register(&*s.users, &*s.hasher, &*s.auth, &*s.events, RegisterInput { username: body.username, email: body.email, password: body.password }).await?; - let resp = AuthResponse { token: out.token, user: to_user_response(&out.user) }; - Ok((StatusCode::CREATED, Json(resp))) -} - -pub async fn post_login(State(s): State, Json(body): Json) -> Result { - let out = login(&*s.users, &*s.hasher, &*s.auth, LoginInput { email: body.email, password: body.password }).await?; - Ok(Json(AuthResponse { token: out.token, user: to_user_response(&out.user) })) -} - -pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { - UserResponse { id: u.id.as_uuid(), username: u.username.to_string(), display_name: u.display_name.clone(), bio: u.bio.clone(), avatar_url: u.avatar_url.clone(), header_url: u.header_url.clone(), local: u.local, created_at: u.created_at } -} -``` - -- [ ] **Write `src/handlers/users.rs`:** - -```rust -use axum::{extract::{Path, State}, Json}; -use api_types::{requests::UpdateProfileRequest, responses::UserResponse}; -use application::use_cases::profile::{get_user_by_username, update_profile}; -use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState}; - -pub async fn get_user(State(s): State, Path(username): Path) -> Result, ApiError> { - let user = get_user_by_username(&*s.users, &username).await?; - Ok(Json(to_user_response(&user))) -} - -pub async fn patch_profile(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result, ApiError> { - update_profile(&*s.users, &uid, body.display_name, body.bio, body.avatar_url, body.header_url, body.custom_css).await?; - let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; - Ok(Json(to_user_response(&user))) -} -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Commit:** -```bash -git add crates/presentation/src/handlers/ -git commit -m "feat(presentation): auth and user handlers" -``` - ---- - -### Task 19: Presentation — thought, feed, social, notification, api-key handlers - -**Files:** `handlers/thoughts.rs`, `handlers/feed.rs`, `handlers/social.rs`, `handlers/notifications.rs`, `handlers/api_keys.rs` - -- [ ] **Write `handlers/thoughts.rs`:** - -```rust -use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, Json}; -use uuid::Uuid; -use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest, PaginationQuery}, responses::{PagedResponse, ThoughtResponse}}; -use application::use_cases::thoughts::{create_thought, delete_thought, edit_thought, get_thread, get_thought, CreateThoughtInput}; -use domain::{models::feed::PageParams, value_objects::ThoughtId}; -use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState}; - -fn to_thought_response(e: domain::models::feed::FeedEntry) -> ThoughtResponse { - ThoughtResponse { - id: e.thought.id.as_uuid(), content: e.thought.content.to_string(), - author: to_user_response(&e.author), in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|x| x.as_uuid()), - visibility: e.thought.visibility.as_str().to_string(), content_warning: e.thought.content_warning, - sensitive: e.thought.sensitive, like_count: e.like_count, boost_count: e.boost_count, - reply_count: e.reply_count, liked_by_viewer: e.liked_by_viewer, boosted_by_viewer: e.boosted_by_viewer, - created_at: e.thought.created_at, updated_at: e.thought.updated_at, - } -} - -pub async fn post_thought(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result { - let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid); - let out = create_thought(&*s.thoughts, &*s.users, &*s.events, CreateThoughtInput { user_id: uid, content: body.content, in_reply_to_id: in_reply_to, visibility: body.visibility, content_warning: body.content_warning, sensitive: body.sensitive.unwrap_or(false) }).await?; - let user = s.users.find_by_id(&out.thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?; - let entry = domain::models::feed::FeedEntry { thought: out.thought, author: user, like_count: 0, boost_count: 0, reply_count: 0, liked_by_viewer: false, boosted_by_viewer: false }; - Ok((StatusCode::CREATED, Json(to_thought_response(entry)))) -} - -pub async fn get_thought_handler(State(s): State, Path(id): Path, OptionalAuthUser(_viewer): OptionalAuthUser) -> Result, ApiError> { - let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?; - let user = s.users.find_by_id(&thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?; - Ok(Json(serde_json::json!({ "id": thought.id.as_uuid(), "content": thought.content.as_str(), "author": { "username": user.username.as_str() } }))) -} - -pub async fn delete_thought_handler(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - delete_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid).await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn patch_thought(State(s): State, AuthUser(uid): AuthUser, Path(id): Path, Json(body): Json) -> Result { - edit_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid, body.content).await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn get_thread_handler(State(s): State, Path(id): Path) -> Result>, ApiError> { - let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?; - let items: Vec<_> = thoughts.iter().map(|t| serde_json::json!({ "id": t.id.as_uuid(), "content": t.content.as_str() })).collect(); - Ok(Json(items)) -} -``` - -- [ ] **Write `handlers/feed.rs`:** - -```rust -use axum::{extract::{Query, State}, Json}; -use api_types::requests::{PaginationQuery, SearchQuery}; -use application::use_cases::feed::{get_home_feed, get_public_feed, search}; -use domain::models::feed::PageParams; -use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, state::AppState}; - -pub async fn home_feed(State(s): State, AuthUser(uid): AuthUser, Query(q): Query) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?; - Ok(Json(serde_json::json!({ "items": result.items.len(), "total": result.total }))) -} - -pub async fn public_feed(State(s): State, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?; - Ok(Json(serde_json::json!({ "items": result.items.len(), "total": result.total }))) -} - -pub async fn search_handler(State(s): State, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query) -> Result, ApiError> { - let page = PageParams { page: q.page.unwrap_or(1), per_page: q.per_page.unwrap_or(20) }; - let result = search(&*s.feed, &q.q, page, viewer.as_ref()).await?; - Ok(Json(serde_json::json!({ "items": result.items.len(), "total": result.total }))) -} -``` - -- [ ] **Write `handlers/social.rs`:** - -```rust -use axum::{extract::{Path, State}, http::StatusCode, Json}; -use uuid::Uuid; -use application::use_cases::social::*; -use application::use_cases::profile::{get_top_friends, set_top_friends}; -use api_types::requests::SetTopFriendsRequest; -use domain::value_objects::{ThoughtId, UserId}; -use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; - -pub async fn post_like(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn delete_like(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn post_boost(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn delete_boost(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn post_follow(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { - follow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn delete_follow(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { - unfollow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn post_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { - block_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn delete_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { - unblock_user(&*s.blocks, &uid, &UserId::from_uuid(target)).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn put_top_friends(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result { - let ids: Vec = body.friend_ids.into_iter().map(UserId::from_uuid).collect(); - set_top_friends(&*s.top_friends, &uid, ids).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn get_top_friends_handler(State(s): State, Path(username): Path) -> Result, ApiError> { - let user = application::use_cases::profile::get_user_by_username(&*s.users, &username).await?; - let friends = get_top_friends(&*s.top_friends, &user.id).await?; - let ids: Vec = friends.iter().map(|(tf, _)| tf.friend_id.as_uuid()).collect(); - Ok(Json(serde_json::json!({ "top_friends": ids }))) -} -``` - -- [ ] **Write `handlers/notifications.rs`:** - -```rust -use axum::{extract::{Path, State}, http::StatusCode, Json}; -use uuid::Uuid; -use domain::{models::feed::PageParams, value_objects::NotificationId}; -use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; - -pub async fn list_notifications(State(s): State, AuthUser(uid): AuthUser) -> Result, ApiError> { - let page = PageParams { page: 1, per_page: 20 }; - let result = s.notifications.list_for_user(&uid, &page).await?; - Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }))) -} -pub async fn mark_notification_read(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?; - Ok(StatusCode::NO_CONTENT) -} -pub async fn mark_all_read(State(s): State, AuthUser(uid): AuthUser) -> Result { - s.notifications.mark_all_read(&uid).await?; - Ok(StatusCode::NO_CONTENT) -} -``` - -- [ ] **Write `handlers/api_keys.rs`:** - -```rust -use axum::{extract::{Path, State}, http::StatusCode, Json}; -use uuid::Uuid; -use api_types::{requests::CreateApiKeyRequest, responses::ApiKeyResponse}; -use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys}; -use domain::value_objects::ApiKeyId; -use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; - -pub async fn get_api_keys(State(s): State, AuthUser(uid): AuthUser) -> Result>, ApiError> { - let keys = list_api_keys(&*s.api_keys, &uid).await?; - Ok(Json(keys.into_iter().map(|k| ApiKeyResponse { id: k.id.as_uuid(), name: k.name, created_at: k.created_at }).collect())) -} -pub async fn post_api_key(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result, ApiError> { - let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await.map_err(domain::errors::DomainError::from)?; - Ok(Json(serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }))) -} -pub async fn delete_api_key_handler(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { - delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?; - Ok(StatusCode::NO_CONTENT) -} -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Commit:** -```bash -git add crates/presentation/src/handlers/ -git commit -m "feat(presentation): thought, feed, social, notification, api-key handlers" -``` - ---- - -### Task 20: Presentation — routes and main - -**Files:** `src/routes.rs`, `src/main.rs`, update `src/lib.rs` - -- [ ] **Write `crates/presentation/src/routes.rs`:** - -```rust -use axum::{routing::{delete, get, patch, post, put}, Router}; -use crate::{handlers::*, state::AppState}; - -pub fn router() -> Router { - Router::new() - // auth - .route("/auth/register", post(auth::post_register)) - .route("/auth/login", post(auth::post_login)) - // users - .route("/users/:username", get(users::get_user)) - .route("/users/me", patch(users::patch_profile)) - .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)) - .route("/users/me/top-friends", put(social::put_top_friends)) - // thoughts - .route("/thoughts", post(thoughts::post_thought)) - .route("/thoughts/:id", get(thoughts::get_thought_handler)) - .route("/thoughts/:id", patch(thoughts::patch_thought)) - .route("/thoughts/:id", delete(thoughts::delete_thought_handler)) - .route("/thoughts/:id/thread", get(thoughts::get_thread_handler)) - // likes & boosts - .route("/thoughts/:id/like", post(social::post_like)) - .route("/thoughts/:id/like", delete(social::delete_like)) - .route("/thoughts/:id/boost", post(social::post_boost)) - .route("/thoughts/:id/boost", delete(social::delete_boost)) - // follows & blocks - .route("/users/:id/follow", post(social::post_follow)) - .route("/users/:id/follow", delete(social::delete_follow)) - .route("/users/:id/block", post(social::post_block)) - .route("/users/:id/block", delete(social::delete_block)) - // feeds - .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)) - .route("/api-keys", post(api_keys::post_api_key)) - .route("/api-keys/:id", delete(api_keys::delete_api_key_handler)) -} -``` - -Add `get_following_handler` and `get_followers_handler` to `handlers/feed.rs`: - -```rust -pub async fn get_following_handler(State(s): State, Path(username): Path, Query(q): Query) -> Result, ApiError> { - let user = application::use_cases::profile::get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = application::use_cases::feed::get_following(&*s.follows, &user.id, page).await?; - Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.len() }))) -} - -pub async fn get_followers_handler(State(s): State, Path(username): Path, Query(q): Query) -> Result, ApiError> { - let user = application::use_cases::profile::get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = application::use_cases::feed::get_followers(&*s.follows, &user.id, page).await?; - Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.len() }))) -} -``` - -- [ ] **Write `crates/presentation/src/main.rs`:** - -```rust -use std::sync::Arc; -use sqlx::PgPool; -use tower_http::cors::CorsLayer; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init(); - - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); - let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET required"); - let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); - - let pool = PgPool::connect(&database_url).await.expect("DB connect failed"); - sqlx::migrate!("../adapters/postgres/migrations").run(&pool).await.expect("Migrations failed"); - - let state = presentation::build_state(pool, jwt_secret); - let app = presentation::routes::router() - .with_state(state) - .layer(CorsLayer::permissive()); - - let addr = format!("0.0.0.0:{port}"); - tracing::info!("Listening on {addr}"); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} -``` - -- [ ] **Write `crates/presentation/src/lib.rs`:** - -```rust -pub mod errors; -pub mod extractors; -pub mod handlers; -pub mod routes; -pub mod state; - -use std::sync::Arc; -use sqlx::PgPool; -use state::AppState; -use domain::testing::NoOpEventPublisher; - -pub fn build_state(pool: PgPool, jwt_secret: String) -> AppState { - AppState { - users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), - thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), - likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), - boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), - follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), - blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), - tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), - api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), - top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), - notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), - remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), - feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), - auth: Arc::new(auth::JwtAuthService::new(jwt_secret, 86400 * 30)), - hasher: Arc::new(auth::Argon2PasswordHasher), - events: Arc::new(NoOpEventPublisher), - } -} -``` - -- [ ] **Run:** `cargo build -p presentation` — Expected: compiles cleanly. - -- [ ] **Smoke test:** Start a local postgres, set env vars, run: -```bash -DATABASE_URL=postgres://... JWT_SECRET=dev cargo run -p presentation -curl -X POST http://localhost:3000/auth/register -H 'content-type: application/json' -d '{"username":"alice","email":"alice@ex.com","password":"pw123"}' -``` -Expected: `201 Created` with `token` in response. - -- [ ] **Commit:** -```bash -git add crates/presentation/ -git commit -m "feat(presentation): routes and main — Plan 1 complete" -``` - ---- - -## Self-Review - -Spec coverage check: -- ✅ Crate structure matches spec (all crates scaffolded) -- ✅ Domain: all entities, value objects, ports, events -- ✅ Postgres: all repos + 3-migration sequence (prod-safe) -- ✅ Auth: JWT + Argon2 -- ✅ Application: all use cases (register, login, thoughts, social, feed, profile, api-keys) -- ✅ Presentation: all REST endpoints, auth extractors, error handling -- ✅ NoOpEventPublisher wired — events publish but are no-ops until Plan 3 -- ⚠️ `sha256_hex` in extractors and `create_api_key` need `sha2`+`hex` crates added to `presentation/Cargo.toml` -- ⚠️ `crate::thought::ThoughtRow` and `crate::user::UserRow` must be `pub(crate)` — noted in tasks -- ⚠️ `create_api_key` returns `Result<_, DomainError>` but uses `Result<(ApiKey, String)>` — fix return type to `Result<(ApiKey, String), DomainError>` -- ✅ Remote actors repo included (needed for Plan 4 federation) -- ✅ Migration strategy: additive only, production UUIDs preserved diff --git a/docs/superpowers/plans/2026-05-14-v2-plan2-search.md b/docs/superpowers/plans/2026-05-14-v2-plan2-search.md deleted file mode 100644 index 0bff178..0000000 --- a/docs/superpowers/plans/2026-05-14-v2-plan2-search.md +++ /dev/null @@ -1,707 +0,0 @@ -# Thoughts v2 — Plan 2: Full-Text Search (postgres-search) - -> **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:** Upgrade search from a full-table-scan ILIKE to indexed trigram search (pg_trgm), returning both thoughts and users from a single `/search` endpoint. - -**Architecture:** A new `SearchPort` trait in domain defines cross-entity search (thoughts + users). `crates/adapters/postgres-search` implements it using `pg_trgm` similarity with GIN indexes. The existing `FeedRepository::search` in `postgres/feed.rs` is also upgraded to use the `%` trigram operator so it benefits from the new index. Presentation adds `search: Arc` to `AppState`. - -**Tech Stack:** Rust, sqlx 0.8, PostgreSQL `pg_trgm` extension, GIN indexes, axum - ---- - -## File Map - -``` -Modified: crates/domain/src/ports.rs ← add SearchPort trait -Modified: crates/domain/src/testing.rs ← add TestStore impl for SearchPort -Modified: crates/adapters/postgres-search/Cargo.toml ← add deps -Modified: crates/adapters/postgres-search/src/lib.rs ← PgSearchRepository (was empty stub) -Create: crates/adapters/postgres/migrations/004_search_indexes.sql -Modified: crates/adapters/postgres/src/feed.rs ← upgrade ILIKE → trigram operator -Modified: crates/presentation/src/state.rs ← add search field -Modified: crates/presentation/src/lib.rs ← wire PgSearchRepository in build_state -Modified: crates/presentation/src/handlers/feed.rs ← search_handler returns thoughts + users -``` - ---- - -### Task 1: Migration — pg_trgm extension and GIN indexes - -**Files:** -- Create: `crates/adapters/postgres/migrations/004_search_indexes.sql` - -- [ ] **Write `004_search_indexes.sql`:** - -```sql -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_thoughts_content_trgm - ON thoughts USING GIN(content gin_trgm_ops); - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_username_trgm - ON users USING GIN(username gin_trgm_ops); - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_display_name_trgm - ON users USING GIN(display_name gin_trgm_ops) - WHERE display_name IS NOT NULL; -``` - -- [ ] **Apply migration to test DB:** - -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ - cargo sqlx migrate run --source crates/adapters/postgres/migrations -``` - -Expected: `Applied 1/migrate search indexes` - -- [ ] **Verify pg_trgm works:** - -```bash -psql postgres://postgres:postgres@localhost:5434/postgres \ - -c "SELECT similarity('hello world', 'hello');" -``` - -Expected: a float value like `0.5` (not an error). - -- [ ] **Commit:** - -```bash -git add crates/adapters/postgres/migrations/004_search_indexes.sql -git commit -m "feat(postgres): pg_trgm extension and GIN search indexes" -``` - ---- - -### Task 2: Domain — SearchPort trait and TestStore implementation - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/testing.rs` - -- [ ] **Write failing test** — add to bottom of `crates/domain/src/testing.rs` (inside `#[cfg(any(test, feature = "test-helpers"))]`): - -```rust -#[cfg(test)] -mod search_tests { - use super::*; - use crate::models::feed::PageParams; - - #[tokio::test] - async fn test_store_search_thoughts_returns_empty() { - let store = TestStore::default(); - let result = store.search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); - assert_eq!(result.total, 0); - } - - #[tokio::test] - async fn test_store_search_users_returns_empty() { - let store = TestStore::default(); - let result = store.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap(); - assert_eq!(result.total, 0); - } -} -``` - -- [ ] **Run:** `cargo test -p domain` — Expected: FAIL (SearchPort not defined yet). - -- [ ] **Add `SearchPort` to `crates/domain/src/ports.rs`** — append after the `FeedRepository` trait: - -```rust -#[async_trait] -pub trait SearchPort: Send + Sync { - /// Full-text search over public thoughts, ranked by trigram similarity. - async fn search_thoughts( - &self, - query: &str, - page: &PageParams, - viewer_id: Option<&UserId>, - ) -> Result, DomainError>; - - /// Search users by username or display_name, ranked by trigram similarity. - async fn search_users( - &self, - query: &str, - page: &PageParams, - ) -> Result, DomainError>; -} -``` - -- [ ] **Add `TestStore impl SearchPort`** in `crates/domain/src/testing.rs` — append after the `impl FeedRepository for TestStore` block: - -```rust -#[async_trait] impl SearchPort for TestStore { - async fn search_thoughts(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn search_users(&self, _q: &str, _p: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } -} -``` - -- [ ] **Run:** `cargo test -p domain` — Expected: all tests PASS. - -- [ ] **Commit:** - -```bash -git add crates/domain/src/ports.rs crates/domain/src/testing.rs -git commit -m "feat(domain): SearchPort trait with thought and user search" -``` - ---- - -### Task 3: postgres-search — PgSearchRepository - -**Files:** -- Modify: `crates/adapters/postgres-search/Cargo.toml` -- Modify: `crates/adapters/postgres-search/src/lib.rs` - -- [ ] **Write failing tests** at bottom of `crates/adapters/postgres-search/src/lib.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::{thought::{Thought, Visibility}, user::User}, - ports::{SearchPort, ThoughtRepository, UserRepository}, - value_objects::*, - }; - - async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { - use postgres::{thought::PgThoughtRepository, user::PgUserRepository}; - let urepo = PgUserRepository::new(pool.clone()); - let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local( - UserId::new(), - Username::new(username).unwrap(), - Email::new(format!("{username}@ex.com")).unwrap(), - PasswordHash("h".into()), - ); - urepo.save(&u).await.unwrap(); - let t = Thought::new_local( - ThoughtId::new(), u.id.clone(), - Content::new_local(content).unwrap(), - None, Visibility::Public, None, false, - ); - trepo.save(&t).await.unwrap(); - (u, t) - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) { - seed_thought(&pool, "alice", "hello world").await; - seed_thought(&pool, "bob", "goodbye universe").await; - let repo = PgSearchRepository::new(pool); - let result = repo.search_thoughts("hello", &domain::models::feed::PageParams { page: 1, per_page: 20 }, None).await.unwrap(); - assert_eq!(result.total, 1); - assert_eq!(result.items[0].thought.content.as_str(), "hello world"); - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_users_finds_by_username(pool: sqlx::PgPool) { - use postgres::user::PgUserRepository; - let urepo = PgUserRepository::new(pool.clone()); - let alice = User::new_local(UserId::new(), Username::new("alice_search").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); - urepo.save(&alice).await.unwrap(); - let repo = PgSearchRepository::new(pool); - let result = repo.search_users("alice", &domain::models::feed::PageParams { page: 1, per_page: 20 }).await.unwrap(); - assert!(!result.items.is_empty()); - assert!(result.items.iter().any(|u| u.username.as_str() == "alice_search")); - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) { - seed_thought(&pool, "alice", "hello world").await; - let repo = PgSearchRepository::new(pool); - let result = repo.search_thoughts("zzzzzzzzz", &domain::models::feed::PageParams { page: 1, per_page: 20 }, None).await.unwrap(); - assert_eq!(result.total, 0); - } -} -``` - -- [ ] **Run:** `cargo test -p postgres-search` — Expected: FAIL (PgSearchRepository not defined). - -- [ ] **Update `crates/adapters/postgres-search/Cargo.toml`:** - -```toml -[package] -name = "postgres-search" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -sqlx = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -async-trait = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["full"] } -sqlx = { workspace = true, features = ["migrate"] } -postgres = { workspace = true } -``` - -Note: `postgres` in dev-dependencies is the internal crate at `crates/adapters/postgres` (already in workspace.dependencies). Add it to workspace.dependencies in root `Cargo.toml` if not already there: - -```toml -# In root Cargo.toml [workspace.dependencies] — verify this line exists: -postgres = { path = "crates/adapters/postgres" } -``` - -- [ ] **Write `crates/adapters/postgres-search/src/lib.rs`:** - -```rust -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use domain::{ - errors::DomainError, - models::{ - feed::{FeedEntry, PageParams, Paginated}, - thought::Thought, - user::User, - }, - ports::SearchPort, - value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, -}; -use domain::models::thought::Visibility; - -pub struct PgSearchRepository { pool: PgPool } -impl PgSearchRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } - -// ── Feed row ───────────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct FeedRow { - thought_id: uuid::Uuid, - t_user_id: uuid::Uuid, - content: String, - in_reply_to_id: Option, - in_reply_to_url: Option, - t_ap_id: Option, - visibility: String, - content_warning: Option, - sensitive: bool, - t_local: bool, - thought_created_at: DateTime, - updated_at: Option>, - author_id: uuid::Uuid, - username: String, - email: String, - password_hash: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - custom_css: Option, - author_local: bool, - u_ap_id: Option, - inbox_url: Option, - public_key: Option, - private_key: Option, - author_created_at: DateTime, - author_updated_at: DateTime, - like_count: i64, - boost_count: i64, - reply_count: i64, -} - -const FEED_SELECT: &str = " - SELECT - t.id AS thought_id, t.user_id AS t_user_id, t.content, - t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id, - t.visibility, t.content_warning, t.sensitive, t.local AS t_local, - t.created_at AS thought_created_at, t.updated_at, - u.id AS author_id, u.username, u.email, u.password_hash, - u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, - u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url, - u.public_key, u.private_key, - u.created_at AS author_created_at, u.updated_at AS author_updated_at, - (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, - (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, - (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count - FROM thoughts t JOIN users u ON u.id=t.user_id"; - -fn row_to_entry(r: FeedRow) -> FeedEntry { - let thought = Thought { - id: ThoughtId::from_uuid(r.thought_id), - user_id: UserId::from_uuid(r.t_user_id), - content: Content::new_remote(r.content), - in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), - in_reply_to_url: r.in_reply_to_url, - ap_id: r.t_ap_id, - visibility: Visibility::from_str(&r.visibility), - content_warning: r.content_warning, - sensitive: r.sensitive, - local: r.t_local, - created_at: r.thought_created_at, - updated_at: r.updated_at, - }; - let author = User { - id: UserId::from_uuid(r.author_id), - username: Username::from_trusted(r.username), - email: Email::from_trusted(r.email), - password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, - avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url, - public_key: r.public_key, private_key: r.private_key, - created_at: r.author_created_at, updated_at: r.author_updated_at, - }; - FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false } -} - -// ── User row ────────────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct UserRow { - id: uuid::Uuid, - username: String, - email: String, - password_hash: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - custom_css: Option, - local: bool, - ap_id: Option, - inbox_url: Option, - public_key: Option, - private_key: Option, - created_at: DateTime, - updated_at: DateTime, -} - -impl From for User { - fn from(r: UserRow) -> Self { - User { - id: UserId::from_uuid(r.id), - username: Username::from_trusted(r.username), - email: Email::from_trusted(r.email), - password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, - avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, - public_key: r.public_key, private_key: r.private_key, - created_at: r.created_at, updated_at: r.updated_at, - } - } -} - -const USER_SELECT: &str = - "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\ - custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users"; - -// ── SearchPort implementation ───────────────────────────────────────────────── - -#[async_trait] -impl SearchPort for PgSearchRepository { - async fn search_thoughts( - &self, - query: &str, - page: &PageParams, - _viewer_id: Option<&UserId>, - ) -> Result, DomainError> { - // Use pg_trgm similarity operator — requires the GIN index from migration 004 - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts t - WHERE t.content % $1 AND t.visibility='public'" - ) - .bind(query) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - let sql = format!( - "{FEED_SELECT} - WHERE t.content % $1 AND t.visibility='public' - ORDER BY similarity(t.content, $1) DESC - LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query_as::<_, FeedRow>(&sql) - .bind(query) - .bind(page.limit()) - .bind(page.offset()) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - Ok(Paginated { - items: rows.into_iter().map(row_to_entry).collect(), - total, - page: page.page, - per_page: page.per_page, - }) - } - - async fn search_users( - &self, - query: &str, - page: &PageParams, - ) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM users u - WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)" - ) - .bind(query) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - let sql = format!( - "{USER_SELECT} - WHERE local=true AND (username % $1 OR display_name % $1) - ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC - LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query_as::<_, UserRow>(&sql) - .bind(query) - .bind(page.limit()) - .bind(page.offset()) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - Ok(Paginated { - items: rows.into_iter().map(User::from).collect(), - total, - page: page.page, - per_page: page.per_page, - }) - } -} -``` - -- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres-search` - Expected: 3 tests pass. - -- [ ] **Commit:** - -```bash -git add crates/adapters/postgres-search/ -git commit -m "feat(postgres-search): PgSearchRepository using pg_trgm" -``` - ---- - -### Task 4: Upgrade postgres ILIKE search to trigram operator - -**Files:** -- Modify: `crates/adapters/postgres/src/feed.rs` - -The current `FeedRepository::search` uses `ILIKE '%pattern%'` which does a full table scan. Upgrade it to use the `%` trigram similarity operator which uses the GIN index from migration 004. - -- [ ] **Update the `search` method** in `crates/adapters/postgres/src/feed.rs`: - -Replace the entire `search` method (lines ~123-136) with: - -```rust - async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'" - ) - .bind(query) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - let sql = format!("{FEED_SELECT} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3"); - let rows = sqlx::query_as::<_, FeedRow>(&sql) - .bind(query) - .bind(page.limit()) - .bind(page.offset()) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) - } -``` - -Also update the existing search test in `feed.rs` — the ILIKE test uses `"hello world"` vs `"hello"`. Trigram similarity works on substrings but with a minimum threshold. Update the test: - -```rust - #[sqlx::test(migrations = "./migrations")] - async fn search_returns_matching_thoughts(pool: sqlx::PgPool) { - let (_, _) = seed(&pool, "alice", "hello world").await; - let (_, _) = seed(&pool, "bob", "goodbye world").await; - let repo = PgFeedRepository::new(pool); - // pg_trgm matches "hello" in "hello world" via trigram similarity - let result = repo.search("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); - assert!(result.total >= 1); - assert!(result.items.iter().any(|e| e.thought.content.as_str() == "hello world")); - } -``` - -Note: use the full string `"hello world"` as query since single short words may fall below the default similarity threshold (0.3). Alternatively, adjust the threshold — but keeping the test realistic is better. - -- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres` - Expected: all tests pass. - -- [ ] **Commit:** - -```bash -git add crates/adapters/postgres/src/feed.rs -git commit -m "feat(postgres): upgrade search from ILIKE to pg_trgm similarity" -``` - ---- - -### Task 5: Wire SearchPort into presentation - -**Files:** -- Modify: `crates/presentation/src/state.rs` -- Modify: `crates/presentation/src/lib.rs` -- Modify: `crates/presentation/src/handlers/feed.rs` - -- [ ] **Add `search` field to `AppState`** in `crates/presentation/src/state.rs`: - -```rust -use std::sync::Arc; -use domain::ports::*; - -#[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, // NEW - pub auth: Arc, - pub hasher: Arc, - pub events: Arc, -} -``` - -- [ ] **Wire `PgSearchRepository` in `build_state`** in `crates/presentation/src/lib.rs`: - -Add `postgres_search` import and the field. The lib.rs `build_state` function currently returns `AppState { ... }` — add one line for `search`: - -```rust -// At top of file, add: -use postgres_search::PgSearchRepository; - -// In build_state, add to the AppState struct literal: -search: Arc::new(PgSearchRepository::new(pool.clone())), -``` - -Also add `postgres-search` to `crates/presentation/Cargo.toml`: - -```toml -postgres-search = { workspace = true } -``` - -- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - -- [ ] **Update `search_handler`** in `crates/presentation/src/handlers/feed.rs` to use `SearchPort` and return both thoughts and users: - -Replace the existing `search_handler` function: - -```rust -pub async fn search_handler( - State(s): State, - OptionalAuthUser(viewer): OptionalAuthUser, - Query(q): Query, -) -> Result, ApiError> { - use domain::models::feed::PageParams; - let page = PageParams { page: q.page.unwrap_or(1), per_page: q.per_page.unwrap_or(20) }; - let query = q.q.trim().to_string(); - - let (thoughts_result, users_result) = tokio::join!( - s.search.search_thoughts(&query, &page, viewer.as_ref()), - s.search.search_users(&query, &page), - ); - - let thoughts = thoughts_result?.items.into_iter().map(|e| serde_json::json!({ - "id": e.thought.id.as_uuid(), - "content": e.thought.content.as_str(), - "author": to_user_response(&e.author), - "like_count": e.like_count, - "boost_count": e.boost_count, - "reply_count": e.reply_count, - "created_at": e.thought.created_at, - })).collect::>(); - - let users = users_result?.items.into_iter().map(|u| to_user_response(&u)).collect::>(); - - Ok(Json(serde_json::json!({ - "query": query, - "thoughts": thoughts, - "users": users, - }))) -} -``` - -Add `use crate::handlers::auth::to_user_response;` at the top of `feed.rs` if not already imported. - -- [ ] **Run:** `cargo build -p presentation` — Expected: clean build. - -- [ ] **Smoke test:** - -```bash -# Start server -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev cargo run -p presentation & -sleep 2 - -# Register + post a thought + search -TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \ - -H 'content-type: application/json' \ - -d '{"username":"searcher","email":"searcher@test.com","password":"pw"}' | jq -r .token) - -curl -s -X POST http://localhost:3000/thoughts \ - -H 'content-type: application/json' \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"content":"searching for trigrams"}' - -curl -s "http://localhost:3000/search?q=trigram" | jq . - -kill %1 -``` - -Expected: JSON with `thoughts` array containing the posted thought, `users` array. - -- [ ] **Commit:** - -```bash -git add crates/presentation/src/state.rs crates/presentation/src/lib.rs \ - crates/presentation/src/handlers/feed.rs crates/presentation/Cargo.toml -git commit -m "feat(presentation): wire SearchPort, upgrade /search to return thoughts + users" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ pg_trgm extension + GIN indexes (Task 1) -- ✅ `SearchPort` trait in domain (Task 2) -- ✅ `postgres-search` crate filled in with `PgSearchRepository` (Task 3) -- ✅ Existing ILIKE upgraded to trigram operator (Task 4) -- ✅ Presentation wired: `search: Arc` in AppState (Task 5) -- ✅ `/search` endpoint returns both thoughts and users (Task 5) - -**Placeholder scan:** None — all code blocks are complete. - -**Type consistency:** -- `SearchPort::search_thoughts` → returns `Paginated` — matches domain model -- `SearchPort::search_users` → returns `Paginated` — matches domain model -- `PgSearchRepository::new(pool: PgPool)` — consistent with all other repo constructors -- `AppState.search: Arc` — consistent with existing fields - -**Notes for implementer:** -- `pg_trgm` `%` operator default threshold is 0.3 — short single-word queries may return no results if the word is too short. The smoke test uses `"trigram"` (7 chars) which is long enough. -- `CONCURRENTLY` in migration lets the index build without locking the table — safe for production. -- `postgres-search` dev-dependency on `postgres` crate is for seeding test data only — no runtime coupling. diff --git a/docs/superpowers/plans/2026-05-14-v2-plan3-events.md b/docs/superpowers/plans/2026-05-14-v2-plan3-events.md deleted file mode 100644 index 498cec4..0000000 --- a/docs/superpowers/plans/2026-05-14-v2-plan3-events.md +++ /dev/null @@ -1,996 +0,0 @@ -# Thoughts v2 — Plan 3: Events + Worker (NATS) - -> **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:** Wire real async event processing — use cases publish domain events to NATS, a worker binary subscribes and runs handlers (NotificationHandler creates DB records; FederationHandler is stubbed for Plan 4). - -**Architecture:** `event-payload/` holds the serializable NATS wire types. `nats/` wraps `async-nats` and implements both `EventPublisher` (publish to NATS) and `EventConsumer` (subscribe, yield `EventEnvelope` stream). `worker/` is a standalone binary that consumes events and dispatches to handlers. `presentation/` swaps its `NoOpEventPublisher` for the real NATS publisher. `event-publisher/` stays a stub (future fan-out to multiple backends). - -**Tech Stack:** Rust, async-nats 0.38, serde_json, futures, async-stream, tokio - -**Prerequisites:** NATS server running locally. Start with: -```bash -docker run -d --name nats -p 4222:4222 nats:latest -# or add to docker-compose if preferred -``` - ---- - -## File Map - -``` -Modified: Cargo.toml ← add async-nats, async-stream to workspace.dependencies -Modified: crates/adapters/event-payload/Cargo.toml ← add deps -Modified: crates/adapters/event-payload/src/lib.rs ← EventPayload enum + subject() + From<&DomainEvent> -Modified: crates/adapters/nats/Cargo.toml ← add deps -Modified: crates/adapters/nats/src/lib.rs ← NatsEventPublisher + NatsEventConsumer -Modified: crates/worker/Cargo.toml ← add deps, add [[bin]] -Create: crates/worker/src/handlers.rs ← NotificationHandler, FederationHandler (stub) -Modified: crates/worker/src/main.rs ← consumer loop binary -Modified: crates/presentation/src/lib.rs ← swap NoOp for NatsEventPublisher -Modified: crates/presentation/Cargo.toml ← add nats dep -``` - ---- - -### Task 1: Workspace deps + event-payload crate - -**Files:** -- Modify: `Cargo.toml` (root workspace) -- Modify: `crates/adapters/event-payload/Cargo.toml` -- Modify: `crates/adapters/event-payload/src/lib.rs` - -- [ ] **Add to root `Cargo.toml` `[workspace.dependencies]`:** - -```toml -async-nats = "0.38" -async-stream = "0.3" - -event-payload = { path = "crates/adapters/event-payload" } -event-publisher = { path = "crates/adapters/event-publisher" } -nats = { path = "crates/adapters/nats" } -``` - -Check if `event-payload`, `event-publisher`, `nats` are already listed — they should be from Plan 1 scaffolding. If so, skip those lines and only add `async-nats` and `async-stream`. - -- [ ] **Write `crates/adapters/event-payload/Cargo.toml`:** - -```toml -[package] -name = "event-payload" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -``` - -- [ ] **Write `crates/adapters/event-payload/src/lib.rs`:** - -```rust -use serde::{Deserialize, Serialize}; - -/// Serializable mirror of domain::events::DomainEvent. -/// All IDs are Strings (UUID hex) — no domain type dependencies. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum EventPayload { - ThoughtCreated { - thought_id: String, - user_id: String, - in_reply_to_id: Option, - }, - ThoughtDeleted { - thought_id: String, - user_id: String, - }, - ThoughtUpdated { - thought_id: String, - user_id: String, - }, - LikeAdded { - like_id: String, - user_id: String, - thought_id: String, - }, - LikeRemoved { - user_id: String, - thought_id: String, - }, - BoostAdded { - boost_id: String, - user_id: String, - thought_id: String, - }, - BoostRemoved { - user_id: String, - thought_id: String, - }, - FollowRequested { - follower_id: String, - following_id: String, - }, - FollowAccepted { - follower_id: String, - following_id: String, - }, - FollowRejected { - follower_id: String, - following_id: String, - }, - Unfollowed { - follower_id: String, - following_id: String, - }, - UserBlocked { - blocker_id: String, - blocked_id: String, - }, -} - -impl EventPayload { - /// Returns the NATS subject for this event. - pub fn subject(&self) -> &'static str { - match self { - Self::ThoughtCreated { .. } => "thoughts.created", - Self::ThoughtDeleted { .. } => "thoughts.deleted", - Self::ThoughtUpdated { .. } => "thoughts.updated", - Self::LikeAdded { .. } => "likes.added", - Self::LikeRemoved { .. } => "likes.removed", - Self::BoostAdded { .. } => "boosts.added", - Self::BoostRemoved { .. } => "boosts.removed", - Self::FollowRequested { .. } => "follows.requested", - Self::FollowAccepted { .. } => "follows.accepted", - Self::FollowRejected { .. } => "follows.rejected", - Self::Unfollowed { .. } => "follows.removed", - Self::UserBlocked { .. } => "users.blocked", - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn thought_created_roundtrip() { - let p = EventPayload::ThoughtCreated { - thought_id: "abc".into(), - user_id: "def".into(), - in_reply_to_id: None, - }; - let json = serde_json::to_string(&p).unwrap(); - let back: EventPayload = serde_json::from_str(&json).unwrap(); - assert_eq!(back.subject(), "thoughts.created"); - } - - #[test] - fn all_subjects_are_unique() { - let samples: &[EventPayload] = &[ - EventPayload::ThoughtCreated { thought_id: "a".into(), user_id: "b".into(), in_reply_to_id: None }, - EventPayload::ThoughtDeleted { thought_id: "a".into(), user_id: "b".into() }, - EventPayload::ThoughtUpdated { thought_id: "a".into(), user_id: "b".into() }, - EventPayload::LikeAdded { like_id: "a".into(), user_id: "b".into(), thought_id: "c".into() }, - EventPayload::LikeRemoved { user_id: "b".into(), thought_id: "c".into() }, - EventPayload::BoostAdded { boost_id: "a".into(), user_id: "b".into(), thought_id: "c".into() }, - EventPayload::BoostRemoved { user_id: "b".into(), thought_id: "c".into() }, - EventPayload::FollowRequested { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::FollowAccepted { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::FollowRejected { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::Unfollowed { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::UserBlocked { blocker_id: "a".into(), blocked_id: "b".into() }, - ]; - let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); - subjects.sort(); - subjects.dedup(); - assert_eq!(subjects.len(), samples.len(), "each event must have a unique subject"); - } -} -``` - -- [ ] **Run:** `cargo test -p event-payload` - Expected: 2 tests pass. - -- [ ] **Commit:** -```bash -git add Cargo.toml crates/adapters/event-payload/ -git commit -m "feat(event-payload): serializable NATS event payload types" -``` - ---- - -### Task 2: nats crate — NatsEventPublisher + NatsEventConsumer - -**Files:** -- Modify: `crates/adapters/nats/Cargo.toml` -- Modify: `crates/adapters/nats/src/lib.rs` - -- [ ] **Write `crates/adapters/nats/Cargo.toml`:** - -```toml -[package] -name = "nats" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -event-payload = { workspace = true } -async-nats = { workspace = true } -async-stream = { workspace = true } -serde_json = { workspace = true } -futures = { workspace = true } -tokio = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } -``` - -- [ ] **Write test** at bottom of `crates/adapters/nats/src/lib.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::value_objects::{ThoughtId, UserId}; - - #[test] - fn payload_from_domain_event_has_correct_subject() { - let event = domain::events::DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }; - let payload = EventPayload::from(&event); - assert_eq!(payload.subject(), "thoughts.created"); - } - - #[test] - fn domain_event_roundtrip_via_payload() { - let uid = UserId::new(); - let tid = ThoughtId::new(); - let event = domain::events::DomainEvent::LikeAdded { - like_id: domain::value_objects::LikeId::new(), - user_id: uid.clone(), - thought_id: tid.clone(), - }; - let payload = EventPayload::from(&event); - let back = domain::events::DomainEvent::try_from(payload).unwrap(); - if let domain::events::DomainEvent::LikeAdded { user_id, thought_id, .. } = back { - assert_eq!(user_id, uid); - assert_eq!(thought_id, tid); - } else { - panic!("wrong variant"); - } - } -} -``` - -- [ ] **Run:** `cargo test -p nats` — Expected: FAIL (lib.rs is empty). - -- [ ] **Write `crates/adapters/nats/src/lib.rs`:** - -```rust -use async_trait::async_trait; -use domain::{ - errors::DomainError, - events::{DomainEvent, EventEnvelope}, - ports::{EventConsumer, EventPublisher}, - value_objects::{BoostId, LikeId, ThoughtId, UserId}, -}; -use event_payload::EventPayload; -use futures::stream::BoxStream; - -// ── DomainEvent → EventPayload ───────────────────────────────────────────── - -impl From<&DomainEvent> for EventPayload { - fn from(e: &DomainEvent) -> Self { - match e { - DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => Self::ThoughtCreated { - thought_id: thought_id.to_string(), - user_id: user_id.to_string(), - in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()), - }, - DomainEvent::ThoughtDeleted { thought_id, user_id } => Self::ThoughtDeleted { - thought_id: thought_id.to_string(), user_id: user_id.to_string(), - }, - DomainEvent::ThoughtUpdated { thought_id, user_id } => Self::ThoughtUpdated { - thought_id: thought_id.to_string(), user_id: user_id.to_string(), - }, - DomainEvent::LikeAdded { like_id, user_id, thought_id } => Self::LikeAdded { - like_id: like_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(), - }, - DomainEvent::LikeRemoved { user_id, thought_id } => Self::LikeRemoved { - user_id: user_id.to_string(), thought_id: thought_id.to_string(), - }, - DomainEvent::BoostAdded { boost_id, user_id, thought_id } => Self::BoostAdded { - boost_id: boost_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(), - }, - DomainEvent::BoostRemoved { user_id, thought_id } => Self::BoostRemoved { - user_id: user_id.to_string(), thought_id: thought_id.to_string(), - }, - DomainEvent::FollowRequested { follower_id, following_id } => Self::FollowRequested { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), - }, - DomainEvent::FollowAccepted { follower_id, following_id } => Self::FollowAccepted { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), - }, - DomainEvent::FollowRejected { follower_id, following_id } => Self::FollowRejected { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), - }, - DomainEvent::Unfollowed { follower_id, following_id } => Self::Unfollowed { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), - }, - DomainEvent::UserBlocked { blocker_id, blocked_id } => Self::UserBlocked { - blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(), - }, - } - } -} - -// ── EventPayload → DomainEvent ───────────────────────────────────────────── - -fn parse_uuid(s: &str, field: &str) -> Result { - uuid::Uuid::parse_str(s) - .map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}"))) -} - -impl TryFrom for DomainEvent { - type Error = DomainError; - - fn try_from(p: EventPayload) -> Result { - Ok(match p { - EventPayload::ThoughtCreated { thought_id, user_id, in_reply_to_id } => DomainEvent::ThoughtCreated { - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - in_reply_to_id: in_reply_to_id - .map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid)) - .transpose()?, - }, - EventPayload::ThoughtDeleted { thought_id, user_id } => DomainEvent::ThoughtDeleted { - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - }, - EventPayload::ThoughtUpdated { thought_id, user_id } => DomainEvent::ThoughtUpdated { - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - }, - EventPayload::LikeAdded { like_id, user_id, thought_id } => DomainEvent::LikeAdded { - like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - }, - EventPayload::LikeRemoved { user_id, thought_id } => DomainEvent::LikeRemoved { - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - }, - EventPayload::BoostAdded { boost_id, user_id, thought_id } => DomainEvent::BoostAdded { - boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?), - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - }, - EventPayload::BoostRemoved { user_id, thought_id } => DomainEvent::BoostRemoved { - user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), - thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), - }, - EventPayload::FollowRequested { follower_id, following_id } => DomainEvent::FollowRequested { - follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), - following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), - }, - EventPayload::FollowAccepted { follower_id, following_id } => DomainEvent::FollowAccepted { - follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), - following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), - }, - EventPayload::FollowRejected { follower_id, following_id } => DomainEvent::FollowRejected { - follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), - following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), - }, - EventPayload::Unfollowed { follower_id, following_id } => DomainEvent::Unfollowed { - follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), - following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), - }, - EventPayload::UserBlocked { blocker_id, blocked_id } => DomainEvent::UserBlocked { - blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), - blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?), - }, - }) - } -} - -// ── NatsEventPublisher ──────────────────────────────────────────────────── - -pub struct NatsEventPublisher { - client: async_nats::Client, -} - -impl NatsEventPublisher { - pub fn new(client: async_nats::Client) -> Self { Self { client } } -} - -#[async_trait] -impl EventPublisher for NatsEventPublisher { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - let payload = EventPayload::from(event); - let subject = payload.subject(); - let bytes = serde_json::to_vec(&payload) - .map_err(|e| DomainError::Internal(e.to_string()))?; - self.client - .publish(subject, bytes.into()) - .await - .map_err(|e| DomainError::Internal(e.to_string())) - } -} - -// ── NatsEventConsumer ───────────────────────────────────────────────────── - -pub struct NatsEventConsumer { - client: async_nats::Client, -} - -impl NatsEventConsumer { - pub fn new(client: async_nats::Client) -> Self { Self { client } } -} - -impl EventConsumer for NatsEventConsumer { - fn consume(&self) -> BoxStream<'_, Result> { - let client = self.client.clone(); - Box::pin(async_stream::try_stream! { - let mut sub = client - .subscribe(">") - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - use futures::StreamExt; - while let Some(msg) = sub.next().await { - let payload = match serde_json::from_slice::(&msg.payload) { - Ok(p) => p, - Err(e) => { - tracing::warn!("failed to deserialize event payload: {e}"); - continue; - } - }; - let event = match DomainEvent::try_from(payload) { - Ok(e) => e, - Err(e) => { - tracing::warn!("failed to convert payload to domain event: {e}"); - continue; - } - }; - // Basic NATS has no ack/nack — at-most-once delivery - yield EventEnvelope { - event, - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - } - }) - } -} -``` - -- [ ] **Run:** `cargo test -p nats` - Expected: 2 tests pass. - -- [ ] **Commit:** -```bash -git add crates/adapters/nats/ -git commit -m "feat(nats): NatsEventPublisher and NatsEventConsumer with payload conversion" -``` - ---- - -### Task 3: worker — NotificationHandler + FederationHandler - -**Files:** -- Modify: `crates/worker/Cargo.toml` -- Create: `crates/worker/src/handlers.rs` - -- [ ] **Write `crates/worker/Cargo.toml`:** - -```toml -[package] -name = "worker" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "thoughts-worker" -path = "src/main.rs" - -[dependencies] -domain = { workspace = true } -nats = { workspace = true } -event-payload = { workspace = true } -postgres = { workspace = true } -async-nats = { workspace = true } -tokio = { workspace = true, features = ["full"] } -futures = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -dotenvy = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -``` - -- [ ] **Write tests** at bottom of `crates/worker/src/handlers.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::{thought::{Thought, Visibility}, user::User}, - testing::TestStore, - value_objects::*, - }; - use std::sync::Arc; - - fn alice() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - #[tokio::test] - async fn like_added_creates_notification_for_thought_author() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - - // alice posts a thought - let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let handler = NotificationHandler { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - - // bob likes alice's thought - handler.handle(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: bob_id.clone(), - thought_id: thought.id.clone(), - }).await.unwrap(); - - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert_eq!(notifs[0].user_id, alice.id); // notification goes to alice - assert!(matches!(notifs[0].notification_type, domain::models::notification::NotificationType::Like)); - } - - #[tokio::test] - async fn self_like_does_not_create_notification() { - let store = TestStore::default(); - let alice = alice(); - let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), - Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let handler = NotificationHandler { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - - handler.handle(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: alice.id.clone(), // alice likes her own thought - thought_id: thought.id.clone(), - }).await.unwrap(); - - assert!(store.notifications.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn follow_accepted_creates_notification() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - store.users.lock().unwrap().push(alice.clone()); - - let handler = NotificationHandler { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - - // bob follows alice (alice gets notified) - handler.handle(&DomainEvent::FollowAccepted { - follower_id: bob_id.clone(), - following_id: alice.id.clone(), - }).await.unwrap(); - - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert_eq!(notifs[0].user_id, alice.id); - assert!(matches!(notifs[0].notification_type, domain::models::notification::NotificationType::Follow)); - } -} -``` - -- [ ] **Run:** `cargo test -p worker` — Expected: FAIL (handlers.rs doesn't exist yet). - -- [ ] **Create `crates/worker/src/handlers.rs`:** - -```rust -use std::sync::Arc; -use chrono::Utc; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::notification::{Notification, NotificationType}, - ports::{NotificationRepository, ThoughtRepository}, - value_objects::NotificationId, -}; - -/// Handles domain events that should create notifications for users. -pub struct NotificationHandler { - pub thoughts: Arc, - pub notifications: Arc, -} - -impl NotificationHandler { - pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - match event { - DomainEvent::LikeAdded { like_id: _, user_id, thought_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), // thought deleted — skip - }; - if thought.user_id == *user_id { return Ok(()); } // no self-notifications - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - notification_type: NotificationType::Like, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await - } - DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if thought.user_id == *user_id { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - notification_type: NotificationType::Boost, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await - } - DomainEvent::FollowAccepted { follower_id, following_id } => { - // The person being followed (following_id) gets notified - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: following_id.clone(), - notification_type: NotificationType::Follow, - from_user_id: Some(follower_id.clone()), - thought_id: None, - read: false, - created_at: Utc::now(), - }).await - } - // All other events: no notification needed in Plan 3 - _ => Ok(()), - } - } -} - -/// Stub handler for ActivityPub federation — implemented in Plan 4. -pub struct FederationHandler; - -impl FederationHandler { - pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - tracing::debug!(event = ?event, "federation handler (stub — Plan 4)"); - Ok(()) - } -} -``` - -- [ ] **Run:** `cargo test -p worker` — Expected: 3 tests pass. - -- [ ] **Commit:** -```bash -git add crates/worker/ -git commit -m "feat(worker): NotificationHandler and FederationHandler stub" -``` - ---- - -### Task 4: worker main binary - -**Files:** -- Modify: `crates/worker/src/main.rs` - -- [ ] **Write `crates/worker/src/main.rs`:** - -```rust -mod handlers; - -use std::sync::Arc; -use futures::StreamExt; -use sqlx::PgPool; -use domain::ports::EventConsumer; - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); - let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()); - - tracing::info!("Connecting to postgres..."); - let pool = PgPool::connect(&database_url).await.expect("DB connect failed"); - - tracing::info!("Connecting to NATS at {nats_url}..."); - let nats_client = async_nats::connect(&nats_url).await.expect("NATS connect failed"); - let consumer = nats::NatsEventConsumer::new(nats_client); - - let notification_handler = handlers::NotificationHandler { - thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), - notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), - }; - let federation_handler = handlers::FederationHandler; - - tracing::info!("Worker started, consuming events..."); - - let mut stream = consumer.consume(); - while let Some(result) = stream.next().await { - match result { - Ok(envelope) => { - let event = &envelope.event; - tracing::debug!(subject = ?event, "received event"); - - let n_result = notification_handler.handle(event).await; - let f_result = federation_handler.handle(event).await; - - if n_result.is_ok() && f_result.is_ok() { - (envelope.ack)(); - } else { - if let Err(e) = n_result { tracing::error!("notification handler error: {e}"); } - if let Err(e) = f_result { tracing::error!("federation handler error: {e}"); } - (envelope.nack)(); - } - } - Err(e) => { - tracing::error!("consumer error: {e}"); - } - } - } -} -``` - -- [ ] **Run:** `cargo build -p worker` - Expected: compiles cleanly (binary `thoughts-worker` produced). - -- [ ] **Smoke test** (requires NATS running): -```bash -# Terminal 1: start NATS if not already running -docker run -d --name nats -p 4222:4222 nats:latest || true - -# Terminal 2: start worker -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ -RUST_LOG=info \ -cargo run --bin thoughts-worker & -sleep 2 - -# Terminal 3: start API server -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev cargo run -p presentation & -sleep 2 - -# Create a user, post a thought, like it — check that worker logs "received event" -TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \ - -H 'content-type: application/json' \ - -d '{"username":"evttest","email":"evt@test.com","password":"pw"}' | jq -r .token) - -TID=$(curl -s -X POST http://localhost:3000/thoughts \ - -H 'content-type: application/json' \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"content":"event test"}' | jq -r .id) - -curl -s -X POST http://localhost:3000/thoughts/$TID/like \ - -H "Authorization: Bearer $TOKEN" - -kill %1 %2 2>/dev/null -``` - -Expected: worker logs show `received event` for the like. No errors. - -- [ ] **Commit:** -```bash -git add crates/worker/src/main.rs -git commit -m "feat(worker): consumer loop binary connecting NATS to handlers" -``` - ---- - -### Task 5: Presentation — swap NoOp for real NatsEventPublisher - -**Files:** -- Modify: `crates/presentation/Cargo.toml` -- Modify: `crates/presentation/src/lib.rs` -- Modify: `crates/presentation/src/main.rs` - -When NATS_URL is not set, fall back to the `NoOpEventPublisher` so the API still starts without NATS. Use an env var `NATS_URL` — if set, use real publisher; if absent, log a warning and use no-op. - -- [ ] **Add `nats` to `crates/presentation/Cargo.toml` deps:** - -```toml -nats = { workspace = true } -async-nats = { workspace = true } -``` - -- [ ] **Update `crates/presentation/src/lib.rs`** — replace the `NoOpEventPublisher` struct and `build_state` function with one that optionally connects to NATS: - -Replace the existing `build_state` signature with an async version: - -```rust -use std::sync::Arc; -use sqlx::PgPool; -use state::AppState; -use async_trait::async_trait; -use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; - -pub mod errors; -pub mod extractors; -pub mod handlers; -pub mod routes; -pub mod state; - -use postgres_search::PgSearchRepository; - -struct NoOpEventPublisher; -#[async_trait] -impl EventPublisher for NoOpEventPublisher { - async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } -} - -pub async fn build_state(pool: PgPool, jwt_secret: String) -> AppState { - let event_publisher: Arc = match std::env::var("NATS_URL") { - Ok(url) => { - match async_nats::connect(&url).await { - Ok(client) => { - tracing::info!("Connected to NATS at {url}"); - Arc::new(nats::NatsEventPublisher::new(client)) - } - Err(e) => { - tracing::warn!("Failed to connect to NATS at {url}: {e} — using no-op publisher"); - Arc::new(NoOpEventPublisher) - } - } - } - Err(_) => { - tracing::info!("NATS_URL not set — using no-op event publisher"); - Arc::new(NoOpEventPublisher) - } - }; - - AppState { - users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), - thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), - likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), - boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), - follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), - blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), - tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), - api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), - top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), - notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), - remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), - feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), - search: Arc::new(PgSearchRepository::new(pool.clone())), - auth: Arc::new(auth::JwtAuthService::new(jwt_secret, 86400 * 30)), - hasher: Arc::new(auth::Argon2PasswordHasher), - events: event_publisher, - } -} -``` - -- [ ] **Update `crates/presentation/src/main.rs`** — `build_state` is now async, so await it: - -```rust -use sqlx::PgPool; -use tower_http::cors::CorsLayer; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); - let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET required"); - let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); - - let pool = PgPool::connect(&database_url).await.expect("DB connect failed"); - sqlx::migrate!("../adapters/postgres/migrations").run(&pool).await.expect("Migrations failed"); - - let state = presentation::build_state(pool, jwt_secret).await; // note: .await - let app = presentation::routes::router() - .with_state(state) - .layer(CorsLayer::permissive()); - - let addr = format!("0.0.0.0:{port}"); - tracing::info!("Listening on {addr}"); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} -``` - -- [ ] **Run:** `cargo build -p presentation` - Expected: clean build. - -- [ ] **Verify no-op fallback works** (without NATS running): -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \ -RUST_LOG=info cargo run -p presentation & -sleep 2 -# Should log: "NATS_URL not set — using no-op event publisher" -curl -s -X POST http://localhost:3000/auth/register \ - -H 'content-type: application/json' \ - -d '{"username":"natstest","email":"nats@test.com","password":"pw"}' | jq .token -kill %1 -``` - -- [ ] **Run full test suite:** -```bash -DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace -``` -Expected: all tests pass (52 + new worker tests = 55+). - -- [ ] **Commit:** -```bash -git add crates/presentation/ -git commit -m "feat(presentation): NatsEventPublisher with no-op fallback when NATS_URL unset" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ event-payload: serializable EventPayload enum, subject(), From/TryFrom conversions (Task 1) -- ✅ nats: NatsEventPublisher implementing EventPublisher (Task 2) -- ✅ nats: NatsEventConsumer implementing EventConsumer via BoxStream (Task 2) -- ✅ worker: NotificationHandler (LikeAdded, BoostAdded, FollowAccepted → notifications) (Task 3) -- ✅ worker: FederationHandler stub (Task 3) -- ✅ worker: consumer loop binary (Task 4) -- ✅ presentation: real NATS publisher with graceful no-op fallback (Task 5) -- ✅ event-publisher: stays as stub (correct — deferred per plan) - -**Placeholder scan:** None — all code blocks complete. - -**Type consistency:** -- `NatsEventPublisher::new(client: async_nats::Client)` — matches usage in presentation lib.rs and worker main.rs -- `NatsEventConsumer::new(client: async_nats::Client)` — matches worker main.rs -- `NotificationHandler { thoughts, notifications }` — field names match handler usage in main.rs -- `build_state` is now `async fn` — main.rs correctly awaits it -- `EventPayload::from(&DomainEvent)` — implemented in nats crate (which sees both types) - -**Notes:** -- Basic NATS (at-most-once delivery) is used — JetStream (exactly-once) deferred to later -- Worker Cargo.toml includes `postgres` internal crate for database access in handlers -- `crates/adapters/nats` has Rust module name `nats` but package name `nats` — import as `use nats::...` diff --git a/docs/superpowers/plans/2026-05-14-v2-plan4-federation.md b/docs/superpowers/plans/2026-05-14-v2-plan4-federation.md deleted file mode 100644 index e59912c..0000000 --- a/docs/superpowers/plans/2026-05-14-v2-plan4-federation.md +++ /dev/null @@ -1,1247 +0,0 @@ -# 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 diff --git a/docs/superpowers/plans/2026-05-15-actor-connections.md b/docs/superpowers/plans/2026-05-15-actor-connections.md deleted file mode 100644 index 43e2bc4..0000000 --- a/docs/superpowers/plans/2026-05-15-actor-connections.md +++ /dev/null @@ -1,1205 +0,0 @@ -# Remote Actor Connections Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Show a remote actor's followers and following as browseable lists within the thoughts UI, backed by a worker cache with concurrent AP profile resolution. - -**Architecture:** New domain models (`ConnectionType`, `ActorConnectionSummary`) + new port (`RemoteActorConnectionRepository`) + new `FederationActionPort` methods. REST endpoints return cached data and fire a `FetchActorConnections` event fire-and-forget. Worker fetches the AP collection, concurrently resolves each actor URL to a profile (5s timeout per actor, partial failures silently skipped), and upserts results. Frontend adds Followers/Following tabs to `RemoteUserProfile` using existing `RemoteUserCard`. - -**Tech Stack:** Rust (axum, sqlx, tokio, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod. - ---- - -## File Map - -| Action | Path | Change | -|--------|------|--------| -| Create | `crates/domain/src/models/connection_type.rs` | `ConnectionType` enum | -| Create | `crates/domain/src/models/actor_connection_summary.rs` | `ActorConnectionSummary` struct | -| Modify | `crates/domain/src/models/mod.rs` | expose new modules | -| Modify | `crates/domain/src/events.rs` | `FetchActorConnections` variant | -| Modify | `crates/domain/src/ports.rs` | `RemoteActorConnectionRepository` port; 2 new `FederationActionPort` methods | -| Modify | `crates/domain/src/testing.rs` | stubs + test | -| Create | `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` | new table | -| Create | `crates/adapters/postgres/src/remote_actor_connections.rs` | postgres impl | -| Modify | `crates/adapters/postgres/src/lib.rs` | expose module, export type | -| Modify | `crates/adapters/activitypub-base/src/service.rs` | impl 2 new port methods | -| Modify | `crates/adapters/event-payload/src/lib.rs` | `FetchActorConnections` variant | -| Modify | `crates/application/src/services/federation_event.rs` | new dep + handler | -| Modify | `crates/worker/src/factory.rs` | wire `remote_actor_connections` | -| Modify | `crates/api-types/src/responses.rs` | `ActorConnectionResponse` | -| Modify | `crates/presentation/src/state.rs` | add `remote_actor_connections` field | -| Modify | `crates/bootstrap/src/factory.rs` | wire new repo | -| Modify | `crates/presentation/src/handlers/federation_actors.rs` | 2 new handlers | -| Modify | `crates/presentation/src/handlers/*.rs` (tests) | add `remote_actor_connections` to `make_state()` | -| Modify | `crates/presentation/src/routes.rs` | mount 2 new routes | -| Modify | `thoughts-frontend/lib/api.ts` | new schema + 2 fetch functions | -| Modify | `thoughts-frontend/components/remote-user-profile.tsx` | replace links with tabs | - ---- - -## Task 1: Domain — models, port, event, stubs - -**Files:** -- Create: `crates/domain/src/models/connection_type.rs` -- Create: `crates/domain/src/models/actor_connection_summary.rs` -- Modify: `crates/domain/src/models/mod.rs` -- Modify: `crates/domain/src/events.rs` -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/testing.rs` - -- [ ] **Step 1: Create `connection_type.rs`** - -```rust -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ConnectionType { - Followers, - Following, -} - -impl ConnectionType { - pub fn as_str(&self) -> &'static str { - match self { - Self::Followers => "followers", - Self::Following => "following", - } - } -} -``` - -- [ ] **Step 2: Create `actor_connection_summary.rs`** - -```rust -#[derive(Debug, Clone)] -pub struct ActorConnectionSummary { - pub url: String, - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} -``` - -- [ ] **Step 3: Register in `models/mod.rs`** - -Add: -```rust -pub mod actor_connection_summary; -pub mod connection_type; -``` - -- [ ] **Step 4: Add `FetchActorConnections` to `DomainEvent`** - -Read `crates/domain/src/events.rs`. Add before the closing brace: -```rust -FetchActorConnections { - actor_ap_url: String, - collection_url: String, - connection_type: String, - page: u32, -}, -``` - -- [ ] **Step 5: Write failing domain test** - -At the bottom of `crates/domain/src/testing.rs`, in the `federation_port_tests` module, add: -```rust -#[tokio::test] -async fn test_store_resolve_actor_profiles_returns_empty() { - let store = TestStore::default(); - let result = store.resolve_actor_profiles(vec!["https://example.com/users/alice".into()]).await; - assert!(result.is_empty()); -} - -#[tokio::test] -async fn test_store_fetch_collection_urls_returns_empty() { - let store = TestStore::default(); - let urls = store.fetch_actor_urls_from_collection("https://example.com/users/alice/followers").await.unwrap(); - assert!(urls.is_empty()); -} -``` - -- [ ] **Step 6: Run to confirm compile failure** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 -``` - -Expected: compile error — new port methods and `RemoteActorConnectionRepository` not defined. - -- [ ] **Step 7: Add `RemoteActorConnectionRepository` to `ports.rs`** - -Read `crates/domain/src/ports.rs`. Add after `RemoteActorRepository`: - -```rust -#[async_trait] -pub trait RemoteActorConnectionRepository: Send + Sync { - async fn upsert_connections( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], - ) -> Result<(), DomainError>; - - async fn list_connections( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - ) -> Result, DomainError>; - - async fn connection_page_age( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - ) -> Result>, DomainError>; -} -``` - -Then in `FederationActionPort`, add two new methods: -```rust -async fn fetch_actor_urls_from_collection( - &self, - collection_url: &str, -) -> Result, DomainError>; - -async fn resolve_actor_profiles( - &self, - urls: Vec, -) -> Vec; -``` - -- [ ] **Step 8: Add stubs to `TestStore`** - -In `crates/domain/src/testing.rs`, add after the existing `impl FederationActionPort for TestStore` block: - -```rust -#[async_trait] -impl RemoteActorConnectionRepository for TestStore { - async fn upsert_connections( - &self, - _actor_url: &str, - _connection_type: &str, - _page: u32, - _actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], - ) -> Result<(), DomainError> { - Ok(()) - } - - async fn list_connections( - &self, - _actor_url: &str, - _connection_type: &str, - _page: u32, - ) -> Result, DomainError> { - Ok(vec![]) - } - - async fn connection_page_age( - &self, - _actor_url: &str, - _connection_type: &str, - _page: u32, - ) -> Result>, DomainError> { - Ok(None) - } -} -``` - -Inside `impl FederationActionPort for TestStore`, add the two new methods: -```rust -async fn fetch_actor_urls_from_collection( - &self, - _collection_url: &str, -) -> Result, DomainError> { - Ok(vec![]) -} - -async fn resolve_actor_profiles( - &self, - _urls: Vec, -) -> Vec { - vec![] -} -``` - -- [ ] **Step 9: Run tests to confirm pass** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 -``` - -Expected: all tests pass. - -- [ ] **Step 10: Full compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5 -``` - -- [ ] **Step 11: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/domain/src/models/connection_type.rs \ - crates/domain/src/models/actor_connection_summary.rs \ - crates/domain/src/models/mod.rs \ - crates/domain/src/events.rs \ - crates/domain/src/ports.rs \ - crates/domain/src/testing.rs -git commit -m "feat(domain): ActorConnectionSummary, ConnectionType, RemoteActorConnectionRepository, FetchActorConnections event" -``` - ---- - -## Task 2: PostgreSQL adapter — migration + repository - -**Files:** -- Create: `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` -- Create: `crates/adapters/postgres/src/remote_actor_connections.rs` -- Modify: `crates/adapters/postgres/src/lib.rs` - -- [ ] **Step 1: Create migration** - -Create `crates/adapters/postgres/migrations/006_remote_actor_connections.sql`: - -```sql -CREATE TABLE remote_actor_connections ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - actor_url TEXT NOT NULL, - connection_type TEXT NOT NULL, - page INT NOT NULL, - connected_actor_url TEXT NOT NULL, - connected_handle TEXT NOT NULL, - connected_display_name TEXT, - connected_avatar_url TEXT, - fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(actor_url, connection_type, page, connected_actor_url) -); -CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at); -``` - -- [ ] **Step 2: Create `remote_actor_connections.rs`** - -Create `crates/adapters/postgres/src/remote_actor_connections.rs`: - -```rust -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::actor_connection_summary::ActorConnectionSummary, - ports::RemoteActorConnectionRepository, -}; -use sqlx::PgPool; - -pub struct PgRemoteActorConnectionRepository { - pool: PgPool, -} - -impl PgRemoteActorConnectionRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository { - async fn upsert_connections( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - actors: &[ActorConnectionSummary], - ) -> Result<(), DomainError> { - for actor in actors { - sqlx::query( - "INSERT INTO remote_actor_connections - (actor_url, connection_type, page, connected_actor_url, - connected_handle, connected_display_name, connected_avatar_url, fetched_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - ON CONFLICT(actor_url, connection_type, page, connected_actor_url) - DO UPDATE SET - connected_handle = EXCLUDED.connected_handle, - connected_display_name = EXCLUDED.connected_display_name, - connected_avatar_url = EXCLUDED.connected_avatar_url, - fetched_at = NOW()", - ) - .bind(actor_url) - .bind(connection_type) - .bind(page as i32) - .bind(&actor.url) - .bind(&actor.handle) - .bind(&actor.display_name) - .bind(&actor.avatar_url) - .execute(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - } - Ok(()) - } - - async fn list_connections( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - ) -> Result, DomainError> { - #[derive(sqlx::FromRow)] - struct Row { - connected_actor_url: String, - connected_handle: String, - connected_display_name: Option, - connected_avatar_url: Option, - } - let rows = sqlx::query_as::<_, Row>( - "SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url - FROM remote_actor_connections - WHERE actor_url = $1 AND connection_type = $2 AND page = $3 - ORDER BY connected_handle", - ) - .bind(actor_url) - .bind(connection_type) - .bind(page as i32) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - Ok(rows - .into_iter() - .map(|r| ActorConnectionSummary { - url: r.connected_actor_url, - handle: r.connected_handle, - display_name: r.connected_display_name, - avatar_url: r.connected_avatar_url, - }) - .collect()) - } - - async fn connection_page_age( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - ) -> Result>, DomainError> { - let row: Option<(Option>,)> = sqlx::query_as( - "SELECT MAX(fetched_at) FROM remote_actor_connections - WHERE actor_url = $1 AND connection_type = $2 AND page = $3", - ) - .bind(actor_url) - .bind(connection_type) - .bind(page as i32) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; - - Ok(row.and_then(|(ts,)| ts)) - } -} -``` - -- [ ] **Step 3: Expose in `postgres/src/lib.rs`** - -Read `crates/adapters/postgres/src/lib.rs`. Add: -```rust -pub mod remote_actor_connections; -``` - -- [ ] **Step 4: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p postgres 2>&1 | tail -10 -``` - -Expected: no errors. - -- [ ] **Step 5: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/adapters/postgres/migrations/006_remote_actor_connections.sql \ - crates/adapters/postgres/src/remote_actor_connections.rs \ - crates/adapters/postgres/src/lib.rs -git commit -m "feat(postgres): remote_actor_connections table + PgRemoteActorConnectionRepository" -``` - ---- - -## Task 3: activitypub-base — implement `fetch_actor_urls_from_collection` + `resolve_actor_profiles` - -**Files:** -- Modify: `crates/adapters/activitypub-base/src/service.rs` - -- [ ] **Step 1: Confirm compile failure** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 -``` - -Expected: error — `fetch_actor_urls_from_collection` and `resolve_actor_profiles` not implemented. - -- [ ] **Step 2: Implement both methods in the `FederationActionPort` impl block** - -Read the file. At the bottom of `impl domain::ports::FederationActionPort for ActivityPubService`, after `fetch_outbox_page`, add: - -```rust -async fn fetch_actor_urls_from_collection( - &self, - collection_url: &str, -) -> Result, domain::errors::DomainError> { - let resp: serde_json::Value = reqwest::Client::new() - .get(collection_url) - .header("Accept", "application/activity+json, application/ld+json") - .send() - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? - .json() - .await - .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; - - let empty = vec![]; - let items = resp["orderedItems"].as_array().unwrap_or(&empty); - Ok(items - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect()) -} - -async fn resolve_actor_profiles( - &self, - urls: Vec, -) -> Vec { - use futures::future; - - async fn fetch_one( - url: String, - ) -> Option { - let resp: serde_json::Value = tokio::time::timeout( - std::time::Duration::from_secs(5), - reqwest::Client::new() - .get(&url) - .header("Accept", "application/activity+json") - .send(), - ) - .await - .ok()? - .ok()? - .json() - .await - .ok()?; - - let ap_url = resp["id"].as_str()?.to_string(); - let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string(); - let domain_str = url::Url::parse(&ap_url) - .ok() - .and_then(|u| u.host_str().map(|s| s.to_string())) - .unwrap_or_default(); - let handle = format!("{}@{}", preferred_username, domain_str); - let display_name = resp["name"].as_str().map(|s| s.to_string()); - let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string()); - - Some(domain::models::actor_connection_summary::ActorConnectionSummary { - url: ap_url, - handle, - display_name, - avatar_url, - }) - } - - let futs: Vec<_> = urls.into_iter().map(fetch_one).collect(); - let results = future::join_all(futs).await; - - results - .into_iter() - .filter_map(|r| { - if r.is_none() { - tracing::warn!("failed to resolve actor profile (timeout or parse error)"); - } - r - }) - .collect() -} -``` - -- [ ] **Step 3: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 -``` - -- [ ] **Step 4: Run all tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 5: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/adapters/activitypub-base/src/service.rs -git commit -m "feat(activitypub-base): impl fetch_actor_urls_from_collection + resolve_actor_profiles (concurrent, 5s timeout)" -``` - ---- - -## Task 4: event-payload — `FetchActorConnections` - -**Files:** -- Modify: `crates/adapters/event-payload/src/lib.rs` - -- [ ] **Step 1: Add variant to `EventPayload` enum** - -Read the file. Add at the end of the enum: -```rust -FetchActorConnections { - actor_ap_url: String, - collection_url: String, - connection_type: String, - page: u32, -}, -``` - -- [ ] **Step 2: Add subject** - -In `subject()`: -```rust -Self::FetchActorConnections { .. } => "federation.fetch_actor_connections", -``` - -- [ ] **Step 3: Add `From<&DomainEvent>` arm** - -```rust -DomainEvent::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, -} => Self::FetchActorConnections { - actor_ap_url: actor_ap_url.clone(), - collection_url: collection_url.clone(), - connection_type: connection_type.clone(), - page: *page, -}, -``` - -- [ ] **Step 4: Add `TryFrom` arm** - -```rust -EventPayload::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, -} => DomainEvent::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, -}, -``` - -- [ ] **Step 5: Add to uniqueness test sample array** - -```rust -EventPayload::FetchActorConnections { - actor_ap_url: "https://mastodon.social/users/alice".into(), - collection_url: "https://mastodon.social/users/alice/followers".into(), - connection_type: "followers".into(), - page: 1, -}, -``` - -- [ ] **Step 6: Test** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -5 -``` - -Expected: all pass (uniqueness test includes new variant). - -- [ ] **Step 7: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/adapters/event-payload/src/lib.rs -git commit -m "feat(event-payload): FetchActorConnections event" -``` - ---- - -## Task 5: Worker — handle `FetchActorConnections` + wire repo - -**Files:** -- Modify: `crates/application/src/services/federation_event.rs` -- Modify: `crates/worker/src/factory.rs` - -- [ ] **Step 1: Add `remote_actor_connections` to `FederationEventService`** - -Read `crates/application/src/services/federation_event.rs`. Add to the struct: -```rust -pub remote_actor_connections: Arc, -``` - -- [ ] **Step 2: Handle `FetchActorConnections` in `process()`** - -Before the `_ => Ok(())` arm, add: - -```rust -DomainEvent::FetchActorConnections { - actor_ap_url, - collection_url, - connection_type, - page, -} => { - let urls = match self - .federation_action - .fetch_actor_urls_from_collection(collection_url) - .await - { - Ok(u) => u, - Err(e) => { - tracing::warn!( - collection_url, - error = %e, - "failed to fetch actor connections collection" - ); - return Ok(()); - } - }; - - if urls.is_empty() { - return Ok(()); - } - - let summaries = self - .federation_action - .resolve_actor_profiles(urls) - .await; - - if summaries.is_empty() { - return Ok(()); - } - - tracing::info!( - count = summaries.len(), - connection_type, - actor = actor_ap_url, - "caching actor connections" - ); - - self.remote_actor_connections - .upsert_connections(actor_ap_url, connection_type, *page, &summaries) - .await?; - - Ok(()) -} -``` - -- [ ] **Step 3: Add test** - -In the `#[cfg(test)]` block, add `remote_actor_connections: Arc::new(store.clone())` to the `svc()` helper, then add: - -```rust -#[tokio::test] -async fn fetch_actor_connections_is_noop_when_collection_empty() { - let store = TestStore::default(); - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::FetchActorConnections { - actor_ap_url: "https://mastodon.social/users/alice".into(), - collection_url: "https://mastodon.social/users/alice/followers".into(), - connection_type: "followers".into(), - page: 1, - }) - .await - .unwrap(); -} -``` - -- [ ] **Step 4: Run tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -10 -``` - -Expected: all pass. - -- [ ] **Step 5: Wire `remote_actor_connections` in `worker/src/factory.rs`** - -Read the file. Add import: -```rust -use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; -``` - -Add the repo: -```rust -let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())) - as Arc; -``` - -Add to `FederationEventService` construction: -```rust -remote_actor_connections: actor_connections, -``` - -- [ ] **Step 6: Compile check** - -```bash -cd /mnt/drive/dev/thoughts && cargo check -p worker 2>&1 | tail -10 -``` - -- [ ] **Step 7: Run all tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 -``` - -- [ ] **Step 8: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/application/src/services/federation_event.rs \ - crates/worker/src/factory.rs -git commit -m "feat(worker): handle FetchActorConnections — resolve and cache remote actor connections" -``` - ---- - -## Task 6: AppState + bootstrap + REST endpoints - -**Files:** -- Modify: `crates/presentation/src/state.rs` -- Modify: `crates/bootstrap/src/factory.rs` -- Modify: `crates/api-types/src/responses.rs` -- Modify: `crates/presentation/src/handlers/federation_actors.rs` -- Modify: `crates/presentation/src/handlers/` (test make_state() helpers) -- Modify: `crates/presentation/src/routes.rs` - -- [ ] **Step 1: Add `remote_actor_connections` to `AppState`** - -Read `crates/presentation/src/state.rs`. Add field: -```rust -pub remote_actor_connections: Arc, -``` - -`RemoteActorConnectionRepository` is in `domain::ports::*`, already imported. - -- [ ] **Step 2: Wire in `bootstrap/src/factory.rs`** - -Read the file. Add import: -```rust -use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; -``` - -Add to `AppState { ... }`: -```rust -remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), -``` - -- [ ] **Step 3: Add `ActorConnectionResponse` to api-types** - -Read `crates/api-types/src/responses.rs`. Add: - -```rust -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ActorConnectionResponse { - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, - pub url: String, -} - -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ActorConnectionPageResponse { - pub items: Vec, - pub page: u32, - pub has_more: bool, -} -``` - -- [ ] **Step 4: Fix broken test `make_state()` helpers** - -Find all handlers with `make_state()` that construct `AppState` — they will now be missing `remote_actor_connections`. Run: -```bash -cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "missing field" | head -5 -``` -For each affected test module, add `remote_actor_connections: store.clone()` to the `AppState` construction. - -- [ ] **Step 5: Add two new handlers to `federation_actors.rs`** - -Read the file. Add imports at the top: -```rust -use api_types::responses::{ActorConnectionPageResponse, ActorConnectionResponse}; -use domain::events::DomainEvent; -``` - -Add after `remote_actor_posts_handler`: - -```rust -const CACHE_TTL_SECS: i64 = 3600; - -pub async fn actor_followers_handler( - State(s): State, - Path(handle): Path, - Query(q): Query, -) -> Result, ApiError> { - actor_connections_handler(s, handle, "followers", q.page() as u32).await -} - -pub async fn actor_following_handler( - State(s): State, - Path(handle): Path, - Query(q): Query, -) -> Result, ApiError> { - actor_connections_handler(s, handle, "following", q.page() as u32).await -} - -async fn actor_connections_handler( - s: AppState, - handle: String, - connection_type: &str, - page: u32, -) -> Result, ApiError> { - const PAGE_SIZE: usize = 20; - - let actor = s.federation.lookup_actor(&handle).await?; - - let collection_url = match connection_type { - "followers" => actor - .followers_url - .ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?, - _ => actor - .following_url - .ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?, - }; - - let items = s - .remote_actor_connections - .list_connections(&actor.url, connection_type, page) - .await?; - - // Fire fetch if cache is missing or stale - let stale = match s - .remote_actor_connections - .connection_page_age(&actor.url, connection_type, page) - .await? - { - None => true, - Some(age) => { - chrono::Utc::now() - .signed_duration_since(age) - .num_seconds() - > CACHE_TTL_SECS - } - }; - - if stale { - let _ = s - .events - .publish(&DomainEvent::FetchActorConnections { - actor_ap_url: actor.url.clone(), - collection_url, - connection_type: connection_type.to_string(), - page, - }) - .await; - } - - let has_more = items.len() >= PAGE_SIZE; - Ok(Json(ActorConnectionPageResponse { - items: items - .into_iter() - .map(|a| ActorConnectionResponse { - handle: a.handle, - display_name: a.display_name, - avatar_url: a.avatar_url, - url: a.url, - }) - .collect(), - page, - has_more, - })) -} -``` - -- [ ] **Step 6: Mount routes** - -Read `crates/presentation/src/routes.rs`. After the existing `/federation/actors/{handle}/posts` route, add: - -```rust -.route( - "/federation/actors/{handle}/followers-list", - get(federation_actors::actor_followers_handler), -) -.route( - "/federation/actors/{handle}/following-list", - get(federation_actors::actor_following_handler), -) -``` - -- [ ] **Step 7: Run all tests** - -```bash -cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 8: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add crates/presentation/src/state.rs \ - crates/bootstrap/src/factory.rs \ - crates/api-types/src/responses.rs \ - crates/presentation/src/handlers/federation_actors.rs \ - crates/presentation/src/routes.rs -# Also add any handler files with updated make_state() -git commit -m "feat(presentation): followers/following list endpoints for remote actors" -``` - ---- - -## Task 7: Frontend — API + tabs in `RemoteUserProfile` - -**Files:** -- Modify: `thoughts-frontend/lib/api.ts` -- Modify: `thoughts-frontend/components/remote-user-profile.tsx` - -- [ ] **Step 1: Add schema + fetch functions to `api.ts`** - -Read the file. After `getActorFollowing`/`getActorFollowers` (or after `getRemoteActorPosts`), add: - -```typescript -export const ActorConnectionSchema = z.object({ - handle: z.string(), - displayName: z.string().nullable(), - avatarUrl: z.string().nullable(), - url: z.string(), -}); -export type ActorConnection = z.infer; - -const ActorConnectionPageSchema = z.object({ - items: z.array(ActorConnectionSchema), - page: z.number(), - hasMore: z.boolean(), -}); - -export const getActorFollowers = ( - handle: string, - page: number, - token: string | null -) => - apiFetch( - `/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, - {}, - ActorConnectionPageSchema, - token - ); - -export const getActorFollowing = ( - handle: string, - page: number, - token: string | null -) => - apiFetch( - `/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, - {}, - ActorConnectionPageSchema, - token - ); -``` - -- [ ] **Step 2: Update `remote-user-profile.tsx`** - -Read the full file. Replace the existing followers/following links section AND add tab state + lazy loading. The component is already `"use client"`. - -Add imports at the top: -```typescript -import { getActorFollowers, getActorFollowing, ActorConnection } from "@/lib/api"; -import { RemoteUserCard } from "@/components/remote-user-card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -``` - -Add state inside the component (after existing state): -```typescript -type Tab = "posts" | "followers" | "following"; -const [activeTab, setActiveTab] = useState("posts"); -const [followers, setFollowers] = useState([]); -const [following, setFollowing] = useState([]); -const [followersPage, setFollowersPage] = useState(1); -const [followingPage, setFollowingPage] = useState(1); -const [followersHasMore, setFollowersHasMore] = useState(false); -const [followingHasMore, setFollowingHasMore] = useState(false); -const [followersLoaded, setFollowersLoaded] = useState(false); -const [followingLoaded, setFollowingLoaded] = useState(false); -``` - -Add tab handlers: -```typescript -const loadFollowers = async (page: number) => { - const result = await getActorFollowers(actor.handle, page, token).catch(() => null); - if (!result) return; - setFollowers((prev) => page === 1 ? result.items : [...prev, ...result.items]); - setFollowersHasMore(result.hasMore); - setFollowersLoaded(true); - setFollowersPage(page); -}; - -const loadFollowing = async (page: number) => { - const result = await getActorFollowing(actor.handle, page, token).catch(() => null); - if (!result) return; - setFollowing((prev) => page === 1 ? result.items : [...prev, ...result.items]); - setFollowingHasMore(result.hasMore); - setFollowingLoaded(true); - setFollowingPage(page); -}; - -const handleTabChange = (tab: string) => { - setActiveTab(tab as Tab); - if (tab === "followers" && !followersLoaded) loadFollowers(1); - if (tab === "following" && !followingLoaded) loadFollowing(1); -}; -``` - -Replace the posts section (`
...`) with: - -```tsx -
- - - Posts - Followers - Following - - - - {initialPosts.length > 0 ? ( - - ) : ( - -

- Posts are being fetched — check back soon. -

-
- )} -
- - - {!followersLoaded ? ( - -

Loading followers…

-
- ) : followers.length === 0 ? ( - -

- No followers cached yet — check back soon. -

-
- ) : ( -
- {followers.map((f) => ( - - ))} - {followersHasMore && ( - - )} -
- )} -
- - - {!followingLoaded ? ( - -

Loading following…

-
- ) : following.length === 0 ? ( - -

- No following cached yet — check back soon. -

-
- ) : ( -
- {following.map((f) => ( - - ))} - {followingHasMore && ( - - )} -
- )} -
-
-
-``` - -Also remove the old `{(actor.followersUrl || actor.followingUrl) && ...}` plain links section from the sidebar — replaced by tabs. - -- [ ] **Step 3: Type-check** - -```bash -cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -10 -``` - -Fix any type errors. Common issue: `RemoteUserCard` expects `RemoteActor` but we're passing `ActorConnection` — both have the same shape (`handle`, `displayName`, `avatarUrl`, `url`) so you may need a cast or to widen the prop type on `RemoteUserCard`. - -If `RemoteUserCard` is typed as `actor: RemoteActor`, change its prop to `actor: { handle: string; displayName: string | null; avatarUrl: string | null; url: string }` or union type. Alternatively, cast: `actor={f as RemoteActor}`. - -- [ ] **Step 4: Commit** - -```bash -cd /mnt/drive/dev/thoughts -git add thoughts-frontend/lib/api.ts \ - thoughts-frontend/components/remote-user-profile.tsx -git commit -m "feat(frontend): followers/following tabs on remote actor profile with lazy loading + pagination" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ `ConnectionType` enum — Task 1 -- ✅ `ActorConnectionSummary` model — Task 1 -- ✅ `RemoteActorConnectionRepository` port — Task 1 -- ✅ `fetch_actor_urls_from_collection` on `FederationActionPort` — Tasks 1 + 3 -- ✅ `resolve_actor_profiles` on `FederationActionPort` (concurrent, 5s timeout, partial) — Tasks 1 + 3 -- ✅ `FetchActorConnections` domain event — Task 1 -- ✅ Migration + `PgRemoteActorConnectionRepository` — Task 2 -- ✅ activitypub-base implements both new methods — Task 3 -- ✅ event-payload wired — Task 4 -- ✅ Worker handles event (fetch collection → resolve profiles → upsert) — Task 5 -- ✅ 1-hour TTL cache logic in endpoint — Task 6 -- ✅ `AppState` + bootstrap wired — Task 6 -- ✅ `ActorConnectionResponse` + `ActorConnectionPageResponse` — Task 6 -- ✅ Two REST endpoints + routes — Task 6 -- ✅ Frontend: schema, fetch fns, tabs with lazy load + pagination — Task 7 -- ✅ Failure handling: partial resolution, warn log, skip — Task 3 - -**Placeholder scan:** None found. - -**Type consistency:** -- `ActorConnectionSummary.url` (domain) → `ActorConnectionResponse.url` (api-types) → `ActorConnection.url` (frontend schema) ✅ -- `connection_type: &str` in port matches `connection_type: String` in event (converted via `.as_str()` when needed) ✅ -- `page: u32` in port, event, endpoint, frontend ✅ -- `RemoteUserCard` prop type — noted in Task 7 step 3 ✅ diff --git a/docs/superpowers/specs/2026-05-14-api-cleanup-design.md b/docs/superpowers/specs/2026-05-14-api-cleanup-design.md deleted file mode 100644 index a509ed8..0000000 --- a/docs/superpowers/specs/2026-05-14-api-cleanup-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# REST API Cleanup Design - -Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation. - -## Route Changes - -| Before | After | Reason | -|--------|-------|--------| -| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround | -| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users | -| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows | -| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename | -| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename | -| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename | -| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name | -| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name | -| `GET /users/me/following-list` | `GET /users/me/following` | verbose name | -| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH | -| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH | -| `PUT /users/me` | removed | `PATCH /users/me` is sufficient | -| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` | - -## Content Negotiation at `GET /users/{username}` - -The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround. - -**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header: - -- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json` -- Anything else → return `UserResponse` with `Content-Type: application/json` - -**Implementation:** - -Add `actor_json(&self, user_id: &UserId) -> Result` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method. - -The unified handler in `presentation/src/handlers/users.rs`: -1. Looks up user by username via `UserRepository` → 404 if not found -2. Checks `Accept` header -3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json` -4. REST path: returns `UserResponse` as before - -The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`. - -## Unified Follow at `POST /users/{username}/follow` - -The handler detects whether `{username}` is a local user or a remote actor: - -```rust -if username.contains('@') { - // Remote: e.g. "gabrielkaszewski@mastodon.social" - s.federation.follow_remote(&uid, &username).await?; -} else { - // Local: look up by username, call follow_user use case - let target = get_user_by_username(&*s.users, &username).await?; - follow_user(&*s.follows, &*s.events, &uid, &target.id).await?; -} -``` - -`POST /federation/follow` and `federation::follow_remote_handler` are deleted. - -## Remote Actor Handle Format Fix - -`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`: - -```rust -let domain = actor.ap_id.host_str().unwrap_or(""); -let full_handle = format!("{}@{}", actor.username, domain); -// RemoteActor { handle: full_handle, ... } -``` - -This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`. - -## Remote Unfollow Scope - -`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature. - -## Notification Endpoints - -Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`. - -- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`) -- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`) - -Both replace their existing `POST` counterparts. - -## Frontend (`thoughts-frontend/lib/api.ts`) - -| Function | Change | -|----------|--------| -| `getUserProfile(username)` | URL: `/users/${username}/profile` → `/users/${username}` | -| `getFollowersList(username)` | URL: `/follower-list` → `/followers` | -| `getFollowingList(username)` | URL: `/following-list` → `/following` | -| `getMeFollowingList()` | URL: `/me/following-list` → `/me/following` | -| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=` → `/users/lookup?handle=` | -| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead | -| `markNotificationRead(id)` | **New** — `PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) | -| `markAllNotificationsRead()` | **New** — `PATCH /notifications` with body `{"read":true}` (no prior frontend impl) | - -Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`. - -## Files Touched - -**Backend:** -- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort` -- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl -- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format -- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile) -- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}` → `{username}` in follow/block; rename follower/following list handlers -- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty -- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH -- `crates/presentation/src/routes.rs` — all route changes -- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest` -- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router - -**Frontend:** -- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above -- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser` -- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`) diff --git a/docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md b/docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md deleted file mode 100644 index 01f322e..0000000 --- a/docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md +++ /dev/null @@ -1,300 +0,0 @@ -# Remote Actor Profile Design - -Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker. - -## Data Flow - -1. User navigates to `/users/@gabrielkaszewski@mastodon.social` -2. Frontend detects `@user@domain` format, calls in parallel: - - `GET /users/lookup?handle=@user@instance` → enriched profile metadata - - `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit) -3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget -4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note` -5. On next visit/refresh posts are populated - -## Domain Changes - -### Extend `domain/src/models/remote_actor.rs` - -Add fields: -```rust -pub struct RemoteActor { - pub url: String, - pub handle: String, - pub display_name: Option, - pub inbox_url: String, - pub shared_inbox_url: Option, - pub public_key: String, - pub avatar_url: Option, - pub last_fetched_at: DateTime, - // new: - pub bio: Option, - pub banner_url: Option, - pub also_known_as: Option, - pub outbox_url: Option, - pub attachment: Vec<(String, String)>, // (name, value) -} -``` - -### New `domain/src/models/remote_note.rs` - -```rust -pub struct RemoteNote { - pub ap_id: String, - pub content: String, - pub published: chrono::DateTime, - pub sensitive: bool, - pub content_warning: Option, -} -``` - -### New `DomainEvent` variant (`domain/src/events.rs`) - -```rust -FetchRemoteActorPosts { - actor_ap_url: String, - outbox_url: String, -} -``` - -### New `FederationActionPort` method (`domain/src/ports.rs`) - -```rust -async fn fetch_outbox_page( - &self, - outbox_url: &str, - page: u32, -) -> Result, DomainError>; -``` - -`TestStore` stub returns `Ok(vec![])`. - -## activitypub-base Implementation - -### `lookup_actor` — populate new `RemoteActor` fields - -Map from `DbActor`: -```rust -bio: actor.bio.clone(), -banner_url: actor.banner_url.as_ref().map(|u| u.to_string()), -also_known_as: actor.also_known_as.clone(), -outbox_url: Some(actor.outbox_url.to_string()), -attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(), -``` - -### `fetch_outbox_page` impl on `ActivityPubService` - -```rust -async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result, DomainError> { - let url = format!("{}?page={}", outbox_url, page); - let resp: serde_json::Value = reqwest::Client::new() - .get(&url) - .header("Accept", "application/activity+json, application/ld+json") - .send().await - .map_err(|e| DomainError::ExternalService(e.to_string()))? - .json().await - .map_err(|e| DomainError::ExternalService(e.to_string()))?; - - let items = resp["orderedItems"].as_array().cloned().unwrap_or_default(); - Ok(items.iter().filter_map(|item| { - // Items are Create activities or Notes directly - let note = if item["type"].as_str() == Some("Create") { - &item["object"] - } else if item["type"].as_str() == Some("Note") { - item - } else { - return None; - }; - // Only public notes - let to = note["to"].as_array()?; - let is_public = to.iter().any(|t| { - t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public") - }); - if !is_public { return None; } - Some(RemoteNote { - ap_id: note["id"].as_str()?.to_string(), - content: note["content"].as_str().unwrap_or("").to_string(), - published: chrono::DateTime::parse_from_rfc3339( - note["published"].as_str()? - ).ok()?.with_timezone(&chrono::Utc), - sensitive: note["sensitive"].as_bool().unwrap_or(false), - content_warning: note["summary"].as_str().map(|s| s.to_string()), - }) - }).collect()) -} -``` - -## AppState + Bootstrap - -Add `ap_repo: Arc` to `presentation/src/state.rs`. - -Wire in `bootstrap/src/factory.rs`: -```rust -ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), -``` - -## event-payload - -Add to `EventPayload` enum: -```rust -FetchRemoteActorPosts { - actor_ap_url: String, - outbox_url: String, -} -``` - -Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test. - -## REST Endpoint - -**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`): - -```rust -pub async fn remote_actor_posts_handler( - State(s): State, - Path(handle): Path, - Query(q): Query, - OptionalAuthUser(viewer): OptionalAuthUser, -) -> Result, ApiError> { - let actor = s.federation.lookup_actor(&handle).await?; - let ap_url = url::Url::parse(&actor.url) - .map_err(|e| ApiError::BadRequest(e.to_string()))?; - - // Get or create interned local UserId for this remote actor - let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? { - Some(id) => id, - None => s.ap_repo.intern_remote_actor(&ap_url).await?, - }; - - // Return cached posts - let page = PageParams { page: q.page(), per_page: q.per_page() }; - let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?; - - // Trigger background fetch (fire and forget) - if let Some(outbox_url) = &actor.outbox_url { - let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts { - actor_ap_url: actor.url.clone(), - outbox_url: outbox_url.clone(), - }).await; - } - - Ok(Json(serde_json::json!({ - "total": result.total, - "page": result.page, - "per_page": result.per_page, - "items": result.items.iter().map(to_thought_response).collect::>(), - }))) -} -``` - -Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`. - -Add `pub mod federation_actors;` to `handlers/mod.rs`. - -Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it. - -## api-types - -Extend `RemoteActorResponse`: -```rust -pub struct RemoteActorResponse { - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, - pub url: String, - // new: - pub bio: Option, - pub banner_url: Option, - pub also_known_as: Option, - pub outbox_url: Option, - pub attachment: Vec, -} - -pub struct ProfileField { - pub name: String, - pub value: String, -} -``` - -Update `lookup_handler` in `users.rs` to populate all new fields. - -## Worker - -### `FederationEventService` new deps - -Add `federation: Arc` and `ap_repo: Arc` to `FederationEventService`. Handle the new event: - -```rust -DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => { - let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await { - Ok(n) => n, - Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); } - }; - let actor_url = url::Url::parse(actor_ap_url) - .map_err(|e| DomainError::ExternalService(e.to_string()))?; - let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?; - for note in notes { - let ap_id = match url::Url::parse(¬e.ap_id) { - Ok(u) => u, - Err(_) => continue, - }; - // accept_note is idempotent — ignore duplicate errors - let _ = self.ap_repo.accept_note( - &ap_id, &author_id, ¬e.content, note.published, - note.sensitive, note.content_warning, "public", - ).await; - } - Ok(()) -} -``` - -Wire new deps in `worker/src/factory.rs`. - -## Frontend - -### `lib/api.ts` - -```typescript -// Enriched RemoteActorSchema (same endpoint, more fields) -export const ProfileFieldSchema = z.object({ - name: z.string(), - value: z.string(), -}); - -export const RemoteActorSchema = z.object({ - handle: z.string(), - displayName: z.string().nullable(), - avatarUrl: z.string().nullable(), - url: z.string(), - bio: z.string().nullable(), - bannerUrl: z.string().nullable(), - alsoKnownAs: z.string().nullable(), - outboxUrl: z.string().nullable(), - attachment: z.array(ProfileFieldSchema), -}); - -export const getRemoteActorPosts = (handle: string, page: number, token: string | null) => - apiFetch( - `/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`, - {}, - z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }), - token - ); -``` - -### `app/users/[username]/page.tsx` - -Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render ``. Otherwise: existing local profile. - -### New `components/remote-user-profile.tsx` - -Client component showing: -- Banner (`bannerUrl`) — full-width image or placeholder -- Avatar + display name + handle (`@user@instance`) -- Bio (rendered as text) -- Profile fields (`attachment`) — key-value table -- "Also known as" link (if present) -- External profile link button → `url` in new tab -- Follow button (reuse `followUser(handle, token)`) -- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon" -- Pagination controls diff --git a/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md b/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md deleted file mode 100644 index 365655e..0000000 --- a/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md +++ /dev/null @@ -1,81 +0,0 @@ -# Remote Actor Search & Follow - -Allows local users to search for and follow users on other ActivityPub instances (e.g. `@user@mastodon.social`) directly from the existing search page. - -## Architecture - -Approach A: new `FederationActionPort` domain trait + dedicated `/federation/*` REST endpoints. Keeps hexagonal arch intact — presentation has no dep on `activitypub-base`. - -## Domain changes - -**`domain/src/models/remote_actor.rs`** — add `avatar_url: Option` - -**`domain/src/errors.rs`** — add `ExternalService(String)` variant - -**`domain/src/ports.rs`** — new trait: - -```rust -pub trait FederationActionPort: Send + Sync { - async fn lookup_actor(&self, handle: &str) -> Result; - async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; -} -``` - -## activitypub-base impl - -`impl domain::ports::FederationActionPort for ActivityPubService` in `service.rs`: - -- `lookup_actor`: calls `webfinger_resolve_actor(handle, &data)` → maps `DbActor` to `domain::RemoteActor` -- `follow_remote`: delegates to existing `self.follow(local_user_id.inner(), handle)` (already handles WebFinger + Follow activity + federation DB record) - -## Bootstrap refactor - -`factory.rs` currently builds `FederationData` + `ApFederationConfig` directly. Switch to `ActivityPubService::new(...)` which creates both internally. `Infrastructure` holds `Arc` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`. - -`AppState` gets one new field: - -```rust -pub federation: Arc, -``` - -Wired to `Arc::clone(&ap_service)` in factory. - -## REST endpoints - -**`api-types/src/responses.rs`** — new: -```rust -pub struct RemoteActorResponse { - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, - pub url: String, -} -``` - -**`presentation/src/handlers/federation.rs`** (new file): - -| Method | Path | Auth | Body | Response | -|--------|------|------|------|----------| -| GET | `/federation/lookup?handle=@user@instance.tld` | none | — | `RemoteActorResponse` | -| POST | `/federation/follow` | bearer | `{"handle":"@user@instance.tld"}` | 204 | - -Mounted in `routes.rs` under `/federation`. - -Error mapping: `DomainError::ExternalService` → 502, `DomainError::NotFound` → 404. - -## Frontend - -**`lib/api.ts`**: -- `RemoteActorSchema` + `RemoteActor` type -- `lookupRemoteActor(handle, token)` → `GET /federation/lookup?handle=...` -- `followRemoteUser(handle, token)` → `POST /federation/follow` - -**`app/search/page.tsx`**: -- Detect `@user@instance.tld` via regex `/^@[\w.-]+@[\w.-]+\.\w+$/` -- If matches: call `lookupRemoteActor` in parallel with local search -- Pass remote actor result to component; show in Users tab above local results - -**`components/remote-user-card.tsx`** (new client component): -- Displays avatar, handle, display name -- Follow button calls `followRemoteUser(handle, token)` -- No unfollow needed for MVP (remote following status not tracked locally) diff --git a/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md b/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md deleted file mode 100644 index 49d56b8..0000000 --- a/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md +++ /dev/null @@ -1,285 +0,0 @@ -# Thoughts v2 — Architecture Rewrite Design - -## Context - -Thoughts is a federated social web service currently running on a monolithic axum + Sea-ORM backend with no domain layer, no traits, and tightly coupled persistence. v2 is a full rewrite targeting: - -- Hexagonal architecture (ports & adapters, zero leakage between layers) -- Full bidirectional ActivityPub federation (Mastodon-compatible Fediverse citizen) -- sqlx with raw SQL — no ORM -- Postgres only (for now), but no coupling to any concrete adapter -- Crate structure mirroring movies-diary (the reference implementation) -- Production data must survive cutover via additive migrations - ---- - -## Crate Structure - -``` -crates/ - domain/ # entities, value objects, ports (traits), domain events - application/ # use cases (commands + queries), no framework deps - api-types/ # request/response DTOs, shared serializable types - presentation/ # axum handlers, routes, extractors, state, openapi — JSON REST only, no HTML rendering (client is Next.js) - worker/ # event consumer loop, dispatches to event handlers - adapters/ - postgres/ # sqlx impls of all repos + migrations/ - postgres-search/ # SearchPort via pg_trgm / tsvector - postgres-federation/ # federation-specific queries (known actors, etc.) - activitypub-base/ # copied from movies-diary — signing, WebFinger, NodeInfo - activitypub/ # thoughts-specific AP objects (Note, Person) + activity handlers - auth/ # JWT AuthService impl - nats/ # EventPublisher + EventConsumer via NATS - event-payload/ # serializable event envelope types (NATS wire format) - event-publisher/ # event routing — domain events → NATS subjects -``` - -**Dependency rule:** `domain` has zero external deps. `application` depends only on `domain`. All adapters depend on `domain` traits only — never on each other. `presentation` and `worker` wire concrete adapters into `Arc` and inject via state. `presentation` never imports from `postgres` directly. - ---- - -## Domain Model - -### Entities & Value Objects - -``` -User — UserId, Username, Email, PasswordHash, DisplayName, Bio, - AvatarUrl, HeaderUrl, local: bool, ap_id: Url, - public_key: String, private_key: Option (None for remote) - -Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote), - in_reply_to: Option, ap_id: Url, - visibility: Public|Followers|Unlisted|Direct, - content_warning: Option, sensitive: bool, local: bool - -Like — LikeId, UserId, ThoughtId, ap_id: Url -Boost — BoostId, UserId, ThoughtId, ap_id: Url -Follow — FollowerId, FollowingId, state: Pending|Accepted|Rejected, ap_id: Url -Block — BlockerId, BlockedId -Tag — TagId, name -ApiKey — ApiKeyId, UserId, key_hash, name -TopFriend — UserId, FriendId, position (1–8) -RemoteActor — url, handle, display_name, inbox_url, shared_inbox_url, public_key -``` - -### Ports (traits in domain, implemented by adapters) - -`UserRepository`, `ThoughtRepository`, `LikeRepository`, `BoostRepository`, -`FollowRepository`, `BlockRepository`, `TagRepository`, `ApiKeyRepository`, -`TopFriendRepository`, `RemoteActorRepository`, `AuthService`, `PasswordHasher`, -`EventPublisher`, `EventConsumer`, `SearchPort`, `SearchCommand` - -### Domain Events - -Published after mutations, consumed by worker for federation and side-effects: - -`ThoughtCreated`, `ThoughtDeleted`, `ThoughtUpdated`, -`LikeAdded`, `LikeRemoved`, -`BoostAdded`, `BoostRemoved`, -`FollowRequested`, `FollowAccepted`, `FollowRejected`, `Unfollowed`, -`UserBlocked` - ---- - -## Application Layer (Use Cases) - -Each use case lives in `application/src/use_cases/` and receives only `&dyn Port` references — no framework types, no sqlx, no axum. Fully testable with mock impls. - -**Commands** (mutate state, publish domain event): -``` -register, login -create_thought, delete_thought, edit_thought -create_reply, delete_reply -like_thought, unlike_thought -boost_thought, unboost_thought -follow_user, unfollow_user, accept_follow, reject_follow -block_user, unblock_user -update_profile, update_top_friends -create_api_key, delete_api_key -handle_inbox ← processes incoming AP activities from remote instances -``` - -**Queries** (read-only, no events): -``` -get_thought, get_thread ← thought + its reply tree -get_home_feed ← thoughts from followed users (local + remote) -get_public_feed ← all local public thoughts -get_user_feed ← one user's public thoughts -get_profile, get_top_friends -get_followers, get_following -list_api_keys -search -get_by_tag -get_notifications -``` - ---- - -## Federation & ActivityPub - -`activitypub-base/` (copied verbatim from movies-diary) handles: HTTP signatures, WebFinger, NodeInfo, generic actor/inbox/outbox/followers HTTP handlers, remote actor fetching. - -`activitypub/` wires `activitypub-base` to the thoughts domain. - -### Outbound (worker: domain event → AP activity → remote inboxes) - -| Domain Event | AP Activity | Destination | -|------------------|---------------------|--------------------------| -| ThoughtCreated | Create(Note) | followers' inboxes | -| ThoughtDeleted | Delete(Note) | followers' inboxes | -| ThoughtUpdated | Update(Note) | followers' inboxes | -| LikeAdded | Like | thought author's inbox | -| LikeRemoved | Undo(Like) | thought author's inbox | -| BoostAdded | Announce | followers' inboxes | -| BoostRemoved | Undo(Announce) | followers' inboxes | -| FollowRequested | Follow | target's inbox | -| FollowAccepted | Accept(Follow) | requester's inbox | -| FollowRejected | Reject(Follow) | requester's inbox | -| Unfollowed | Undo(Follow) | target's inbox | -| UserBlocked | Block | blocked user's inbox | - -### Inbound (`handle_inbox` use case) - -| Incoming Activity | Use Case invoked | -|-------------------|----------------------------| -| Create(Note) | create_thought (remote) | -| Delete | delete_thought (remote) | -| Update(Note) | edit_thought (remote) | -| Like | like_thought (remote) | -| Undo(Like) | unlike_thought (remote) | -| Announce | boost_thought (remote) | -| Undo(Announce) | unboost_thought (remote) | -| Follow | follow_user → auto-accept (public accounts) / pending (locked accounts) | -| Accept(Follow) | accept_follow | -| Reject(Follow) | reject_follow | -| Undo(Follow) | unfollow_user | -| Block | block_user (remote) | - -### AP Endpoints (in presentation/) - -``` -GET /.well-known/webfinger -GET /.well-known/nodeinfo -GET /nodeinfo/2.0 -GET /users/:username ← Actor object -GET /users/:username/inbox -POST /users/:username/inbox ← receives remote activities -GET /users/:username/outbox -GET /users/:username/followers -GET /users/:username/following -``` - ---- - -## Database Schema & Migration Strategy - -### Remote thought caching - -`likes` and `boosts` reference `thought_id UUID REFERENCES thoughts(id)`. When a local user likes or boosts a remote thought, the remote Note is first fetched and cached as a row in `thoughts` with `local = false`. This keeps referential integrity and allows rendering liked/boosted remote content without additional AP lookups. - -### Migration approach - -sqlx `migrations/` in `adapters/postgres/`. First migration recreates existing schema in sqlx format (matching production exactly, preserving all UUIDs). Subsequent migrations are additive only — no destructive changes. - -### Additive changes to existing tables - -```sql --- users: federation -ALTER TABLE users ADD COLUMN ap_id TEXT UNIQUE; -ALTER TABLE users ADD COLUMN inbox_url TEXT; -ALTER TABLE users ADD COLUMN public_key TEXT; -ALTER TABLE users ADD COLUMN private_key TEXT; -- NULL for remote users -ALTER TABLE users ADD COLUMN local BOOLEAN NOT NULL DEFAULT true; - --- thoughts: replies + AP + visibility -ALTER TABLE thoughts ADD COLUMN in_reply_to_id UUID REFERENCES thoughts(id); -ALTER TABLE thoughts ADD COLUMN in_reply_to_url TEXT; -- remote parent -ALTER TABLE thoughts ADD COLUMN ap_id TEXT UNIQUE; -ALTER TABLE thoughts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'; -ALTER TABLE thoughts ADD COLUMN content_warning TEXT; -ALTER TABLE thoughts ADD COLUMN sensitive BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE thoughts ADD COLUMN local BOOLEAN NOT NULL DEFAULT true; -ALTER TABLE thoughts ADD COLUMN updated_at TIMESTAMPTZ; - --- follows: pending state + AP id -ALTER TABLE follows ADD COLUMN state TEXT NOT NULL DEFAULT 'accepted'; -ALTER TABLE follows ADD COLUMN ap_id TEXT; -ALTER TABLE follows ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); -``` - -### New tables - -```sql -CREATE TABLE likes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - thought_id UUID NOT NULL REFERENCES thoughts(id), - ap_id TEXT UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, thought_id) -); - -CREATE TABLE boosts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - thought_id UUID NOT NULL REFERENCES thoughts(id), - ap_id TEXT UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, thought_id) -); - -CREATE TABLE blocks ( - blocker_id UUID NOT NULL REFERENCES users(id), - blocked_id UUID NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (blocker_id, blocked_id) -); - -CREATE TABLE remote_actors ( - url TEXT PRIMARY KEY, - handle TEXT NOT NULL, - display_name TEXT, - inbox_url TEXT NOT NULL, - shared_inbox_url TEXT, - public_key TEXT NOT NULL, - last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - type TEXT NOT NULL, -- 'like','boost','follow','mention','reply' - from_user_id UUID REFERENCES users(id), - thought_id UUID REFERENCES thoughts(id), - read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -``` - ---- - -## Event System & Worker - -### event-payload/ -Serializable wire types for NATS. Mirror of domain events with all fields as primitives (UUIDs as strings). `serde::Serialize/Deserialize`. No domain dependency. - -### event-publisher/ -Receives `DomainEvent`, serializes to event-payload, routes to NATS subject (e.g. `thoughts.created`, `likes.added`). Implements domain's `EventPublisher` trait. - -### nats/ -Wraps `async-nats`. Implements `EventPublisher` (publish to subject) and `EventConsumer` (subscribe, yields `EventEnvelope` stream with ack/nack handles). - -### worker/ (binary) -``` -EventConsumer::consume() - → deserialize EventEnvelope - → match event type → dispatch to EventHandler impl - → ack on success, nack on failure (NATS redelivers) - -Handlers: - FederationHandler ← domain events → AP activities → remote inboxes - NotificationHandler ← writes notifications on like/boost/follow/mention/reply - SearchIndexHandler ← indexes/removes documents on create/delete -``` - -Handlers are plain structs taking `Arc` — no NATS coupling inside them. Worker `main.rs` wires everything together. diff --git a/docs/superpowers/specs/2026-05-15-actor-connections-design.md b/docs/superpowers/specs/2026-05-15-actor-connections-design.md deleted file mode 100644 index ebc697a..0000000 --- a/docs/superpowers/specs/2026-05-15-actor-connections-design.md +++ /dev/null @@ -1,213 +0,0 @@ -# Remote Actor Connections (Followers/Following) Design - -Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution. - -## Data Flow - -1. User opens the Followers or Following tab on a remote actor profile -2. Frontend calls `GET /federation/actors/{handle}/followers-list?page=1` -3. Backend returns cached data immediately (may be empty on first visit) -4. If cache is empty OR older than 1 hour: publish `FetchActorConnections` event fire-and-forget -5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results -6. Next visit / tab re-open shows populated data - -## Domain Changes - -### New models (`domain/src/models/`) - -**`connection_type.rs`**: -```rust -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ConnectionType { - Followers, - Following, -} - -impl ConnectionType { - pub fn as_str(&self) -> &'static str { - match self { Self::Followers => "followers", Self::Following => "following" } - } -} -``` - -**`actor_connection_summary.rs`**: -```rust -#[derive(Debug, Clone)] -pub struct ActorConnectionSummary { - pub url: String, // AP URL of the connected actor - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} -``` - -### New `DomainEvent` variant (`domain/src/events.rs`) - -```rust -FetchActorConnections { - actor_ap_url: String, - collection_url: String, - connection_type: String, // "followers" | "following" - page: u32, -}, -``` - -### New port (`domain/src/ports.rs`) - -```rust -pub trait RemoteActorConnectionRepository: Send + Sync { - async fn upsert_connections( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - actors: &[ActorConnectionSummary], - ) -> Result<(), DomainError>; - - async fn list_connections( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - ) -> Result, DomainError>; - - async fn connection_page_age( - &self, - actor_url: &str, - connection_type: &str, - page: u32, - ) -> Result>, DomainError>; -} -``` - -### New `FederationActionPort` method - -```rust -async fn resolve_actor_profiles( - &self, - urls: Vec, -) -> Vec; -``` - -Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged). - -## Storage - -### Migration: `006_remote_actor_connections.sql` - -```sql -CREATE TABLE remote_actor_connections ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - actor_url TEXT NOT NULL, - connection_type TEXT NOT NULL, - page INT NOT NULL, - connected_actor_url TEXT NOT NULL, - connected_handle TEXT NOT NULL, - connected_display_name TEXT, - connected_avatar_url TEXT, - fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(actor_url, connection_type, page, connected_actor_url) -); -CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at); -``` - -### `PgRemoteActorConnectionRepository` - -- `upsert_connections`: `INSERT ... ON CONFLICT DO UPDATE SET connected_handle=EXCLUDED.connected_handle, connected_display_name=EXCLUDED.connected_display_name, connected_avatar_url=EXCLUDED.connected_avatar_url, fetched_at=NOW()` -- `list_connections`: `SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle` -- `connection_page_age`: `SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3` - -## activitypub-base: `resolve_actor_profiles` - -`ActivityPubService` implements `FederationActionPort::resolve_actor_profiles`: - -1. For each URL: spawn `tokio::time::timeout(5s, fetch_actor_profile(url))` -2. `fetch_actor_profile`: `GET {url}` with `Accept: application/activity+json` → parse `preferred_username`, `name`, `icon.url`, `id` -3. Collect `Ok` results → return as `Vec` -4. Failed/timed-out actors: `tracing::warn!` and skip - -## event-payload - -Add `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }` to `EventPayload` — subject: `"federation.fetch_actor_connections"`. Add to `From<&DomainEvent>`, `TryFrom`, and uniqueness test. - -## Worker - -`FederationEventService` gains `remote_actor_connections: Arc`. - -Handler for `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }`: - -1. Fetch `collection_url` (as AP JSON) → extract `orderedItems` array as Vec of URL strings -2. If empty: return Ok(()) — nothing to store -3. `federation_action.resolve_actor_profiles(urls).await` — concurrent, partial success OK -4. `remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await` -5. Log: `tracing::info!(count = results.len(), "actor connections cached")` - -Wire `remote_actor_connections` in `worker/src/factory.rs`. - -## AppState + Bootstrap - -Add `remote_actor_connections: Arc` to `AppState`. Wire `PgRemoteActorConnectionRepository` in `bootstrap/src/factory.rs`. - -## REST Endpoints - -**`GET /federation/actors/{handle}/followers-list?page=1`** - -``` -1. lookup_actor(handle) → get actor_ap_url + followers_url -2. list_connections(actor_ap_url, "followers", page) → cached items -3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget) -4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE } -``` - -`PAGE_SIZE = 20`. `has_more` tells the frontend whether to show a "next" button. - -**`GET /federation/actors/{handle}/following-list?page=1`** — identical, uses `following_url` and `"following"`. - -Response item shape (reuses `RemoteActorResponse` minus `bio`/`banner`/`attachment`/`outbox_url`): -```json -{ "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." } -``` - -Define as a new `ActorConnectionResponse` in api-types. - -Mount both routes in `routes.rs`. Add new handler file `federation_actors.rs` (already exists — add to it). - -## Frontend - -### `lib/api.ts` - -```typescript -export const ActorConnectionSchema = z.object({ - handle: z.string(), - displayName: z.string().nullable(), - avatarUrl: z.string().nullable(), - url: z.string(), -}); -export type ActorConnection = z.infer; - -const ConnectionPageSchema = z.object({ - items: z.array(ActorConnectionSchema), - page: z.number(), - hasMore: z.boolean(), -}); - -export const getActorFollowers = (handle, page, token) => - apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token); - -export const getActorFollowing = (handle, page, token) => - apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token); -``` - -### `RemoteUserProfile` changes - -Replace the plain "Followers / Following" link section with two client-side tabs. Each tab: -- Shows a list of `RemoteUserCard` components (reuse existing) -- "Load more" button if `hasMore` -- Empty state: "Loading — check back soon." -- Tab is lazy: only fetches when first opened (not on profile load) - -Use the existing `RemoteUserCard` component — it already handles follow button and linking. - -### `remote-user-profile.tsx` note - -The component is already a client component (`"use client"`), so React state for tab selection and paginated data works fine. Each tab fetches via `getActorFollowers`/`getActorFollowing` when first activated.