This commit is contained in:
2026-05-15 01:25:16 +02:00
parent 0734ef20c6
commit 4cd94b3c7f
24 changed files with 1 additions and 17402 deletions

1
Cargo.lock generated
View File

@@ -29,6 +29,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"enum_delegate", "enum_delegate",
"futures",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -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<dyn ActivityPubRepository>` 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<dyn ActivityPubRepository>
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<Vec<OutboxEntry>, 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<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, 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<Option<UserId>, 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<UserId, DomainError>;
// ── 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<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
) -> 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<u64, DomainError>;
}
```
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<Vec<crate::ports::OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn outbox_page_for_actor(&self, _uid: &UserId, _before: Option<chrono::DateTime<chrono::Utc>>, _limit: usize) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result<Option<UserId>, 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<UserId, DomainError> {
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<chrono::Utc>, _sensitive: bool, _content_warning: Option<String>) -> 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<u64, DomainError> {
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<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String, updated_at: Option<DateTime<Utc>> }
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<DateTime<Utc>>, limit: usize) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String, updated_at: Option<DateTime<Utc>> }
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<Option<UserId>, 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<UserId, DomainError> {
// 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<Utc>, sensitive: bool, content_warning: Option<String>) -> 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<u64, DomainError> {
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<dyn ActivityPubRepository>,
urls: ThoughtsUrls,
}
impl ThoughtsObjectHandler {
pub fn new(repo: Arc<dyn ActivityPubRepository>, 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<Vec<(Url, serde_json::Value)>> {
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(&note)?))
}).collect()
}
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
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(&note)?, 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,
&note.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, &note.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<u64> {
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<dyn ActivityPubRepository>, 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<dyn ActivityPubRepository>`
-`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<dyn ActivityPubRepository>, 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)

File diff suppressed because it is too large Load Diff

View File

@@ -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<EventPayload> 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<RegisterOutput, DomainError> {
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<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
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<EventPayload> (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

View File

@@ -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<dyn Port>` 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<dyn Port>` 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<String>,
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<dyn EventPublisher> = 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<dyn Port>
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<dyn UserRepository>,
pub thoughts: Arc<dyn ThoughtRepository>,
pub likes: Arc<dyn LikeRepository>,
pub boosts: Arc<dyn BoostRepository>,
pub follows: Arc<dyn FollowRepository>,
pub blocks: Arc<dyn BlockRepository>,
pub tags: Arc<dyn TagRepository>,
pub api_keys: Arc<dyn ApiKeyRepository>,
pub top_friends: Arc<dyn TopFriendRepository>,
pub notifications: Arc<dyn NotificationRepository>,
pub remote_actors: Arc<dyn RemoteActorRepository>,
pub feed: Arc<dyn FeedRepository>,
pub search: Arc<dyn SearchPort>,
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
}
```
- [ ] **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<dyn Port>` 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<dyn Port>` (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.

View File

@@ -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<T: Transport>`, 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<T>
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<NatsTransport>
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<Mutex<Vec<(String, Vec<u8>)>>>,
}
impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
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<T: Transport> {
transport: T,
}
impl<T: Transport> EventPublisherAdapter<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
}
#[async_trait]
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
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<EventEnvelope, DomainError>> {
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::<EventPayload>(&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<NatsTransport> — transport-agnostic event publishing"
```
---
## Self-Review
**Spec coverage:**
-`Transport` trait in `event-publisher` with `publish_bytes(subject, bytes)` (Task 1)
-`EventPublisherAdapter<T: Transport>` 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>``NatsTransport` implements `Transport`, `EventPublisherAdapter<T: Transport>` 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(...)))
```

View File

@@ -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<S>`) so both publish and subscribe are transport-agnostic.
**Architecture after this plan:**
```
event-transport/ ← Transport + EventPublisherAdapter<T> (existing)
← MessageSource + EventConsumerAdapter<S> (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<NatsMessageSource>
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<u8> }
#[async_trait]
impl MessageSource for OneMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
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<RawMessage, DomainError>> {
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<u8>,
pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>,
}
/// Abstraction over any subscribe/consume backend.
/// Implement this for NATS, Kafka, Redis Streams, etc.
pub trait MessageSource: Send + Sync {
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
}
/// 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<S: MessageSource> {
source: S,
}
impl<S: MessageSource> EventConsumerAdapter<S> {
pub fn new(source: S) -> Self { Self { source } }
}
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
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::<EventPayload>(&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<RawMessage, DomainError>> {
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<NatsMessageSource> — 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<RawMessage>` (Task 2)
-`EventConsumerAdapter<S: MessageSource>` 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>``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 ✓

View File

@@ -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<String> = blocked.into_iter().collect();
let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
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<Vec<ThoughtId>>,
deleted: Mutex<Vec<String>>,
updated: Mutex<Vec<ThoughtId>>,
announced: Mutex<Vec<String>>,
undo_announced: Mutex<Vec<String>>,
}
```
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<FederationData>,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Option<(crate::actors::DbActor, Vec<Url>)>> {
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<String> = blocked.into_iter().collect();
let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
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<Url>)>` — caller destructures with `let Some(...) = ... else { return Ok(()) }`

File diff suppressed because it is too large Load Diff

View File

@@ -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<AppState>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>(),
"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<AppState>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>(),
"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<AppState>,
Path(username): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>(),
})))
}
```
- [ ] **Fix `tag_thoughts_handler`** — same:
```rust
pub async fn tag_thoughts_handler(
State(s): State<AppState>,
Path(tag_name): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>(),
})))
}
```
NOTE: `get_by_tag` returns `Paginated<Thought>`, not `Paginated<FeedEntry>` — it won't have author or counts. Check the use case signature. If it returns `Paginated<Thought>`, map manually keeping available fields only (id, content, visibility, dates). If it returns `Paginated<FeedEntry>`, 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 7580). 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<i64, DomainError>;
```
- [ ] **Implement `count()` in postgres** — find `impl UserRepository for PgUserRepository` in `crates/adapters/postgres/src/user.rs` and add:
```rust
async fn count(&self) -> Result<i64, DomainError> {
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<i64, DomainError> {
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<String>, Query, description = "Search query"),
PaginationQuery,
),
responses((status = 200, description = "User list"))
)]
pub async fn get_users(
State(s): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
) -> Result<Json<serde_json::Value>, 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<Vec<(String, i64)>, 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<Vec<(String, i64)>, 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<Vec<(String, i64)>, 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<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
})))
}
```
- [ ] **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<String>,
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<u32>,
}
```
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<HeaderValue> = 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<Thought>` — 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

View File

@@ -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<Uuid>,
/// One of: "public", "followers", "unlisted", "direct"
pub visibility: Option<String>,
pub content_warning: Option<String>,
pub sensitive: Option<bool>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct EditThoughtRequest {
pub content: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateProfileRequest {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct SetTopFriendsRequest {
/// Ordered list of user UUIDs, max 8
pub friend_ids: Vec<Uuid>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateApiKeyRequest {
pub name: String,
}
#[derive(Deserialize, utoipa::IntoParams)]
pub struct PaginationQuery {
pub page: Option<u64>,
pub per_page: Option<u64>,
}
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<u64>,
pub per_page: Option<u64>,
}
```
- [ ] **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<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub local: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, Clone, utoipa::ToSchema)]
pub struct ThoughtResponse {
pub id: Uuid,
pub content: String,
pub author: UserResponse,
pub in_reply_to_id: Option<Uuid>,
pub visibility: String,
pub content_warning: Option<String>,
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<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct PagedResponse<T: Serialize + utoipa::ToSchema> {
pub items: Vec<T>,
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<Utc>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct NotificationResponse {
pub id: Uuid,
pub notification_type: String,
pub from_user: Option<UserResponse>,
pub thought_id: Option<Uuid>,
pub read: bool,
pub created_at: DateTime<Utc>,
}
#[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<ApiKeyResponse>)),
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<S: Clone + Send + Sync + 'static>(router: Router<S>) -> Router<S> {
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<AppState> {
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<AppState>` 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<S>` generic — works with `Router<AppState>` 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

File diff suppressed because it is too large Load Diff

View File

@@ -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<String>,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub public_key: String,
pub avatar_url: Option<String>, // ← add this
pub last_fetched_at: DateTime<Utc>,
}
```
- [ ] **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<RemoteActor, DomainError>;
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<RemoteActor, DomainError> {
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<domain::models::remote_actor::RemoteActor, domain::errors::DomainError> {
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<dyn UserRepository>,
pub thoughts: Arc<dyn ThoughtRepository>,
pub likes: Arc<dyn LikeRepository>,
pub boosts: Arc<dyn BoostRepository>,
pub follows: Arc<dyn FollowRepository>,
pub blocks: Arc<dyn BlockRepository>,
pub tags: Arc<dyn TagRepository>,
pub api_keys: Arc<dyn ApiKeyRepository>,
pub top_friends: Arc<dyn TopFriendRepository>,
pub notifications: Arc<dyn NotificationRepository>,
pub remote_actors: Arc<dyn RemoteActorRepository>,
pub feed: Arc<dyn FeedRepository>,
pub search: Arc<dyn SearchPort>,
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
pub federation: Arc<dyn FederationActionPort>, // ← 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<ActivityPubService>,
}
```
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<dyn FederationActionPort>,
```
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<String>,
pub avatar_url: Option<String>,
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<AppState>,
Query(_q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, ApiError> {
todo!()
}
pub async fn follow_remote_handler(
State(_s): State<AppState>,
AuthUser(_uid): AuthUser,
Json(_body): Json<FollowRemoteRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, 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<AppState>,
AuthUser(uid): AuthUser,
Json(body): Json<FollowRemoteRequest>,
) -> Result<StatusCode, ApiError> {
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<typeof RemoteActorSchema>;
```
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 (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<UserAvatar
username={actor.handle}
avatarUrl={actor.avatarUrl}
size="md"
/>
<div>
<p className="font-medium">{actor.displayName ?? actor.handle}</p>
<p className="text-sm text-muted-foreground">{actor.handle}</p>
</div>
</div>
<Button
onClick={handleFollow}
disabled={loading || followed}
variant={followed ? "secondary" : "default"}
size="sm"
>
<UserPlus className="mr-2 h-4 w-4" />
{followed ? "Requested" : "Follow"}
</Button>
</div>
);
}
```
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 (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
<p className="text-muted-foreground">
Find users and thoughts across the platform.
</p>
</div>
);
}
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<string, { avatarUrl?: string | null }>();
if (results) {
results.users.forEach((user: User) => {
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
});
}
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground">
Showing results for: &quot;{query}&quot;
</p>
</header>
<main>
{isHandle ? (
remoteActor ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Remote user</h2>
<RemoteUserCard actor={remoteActor} />
</div>
) : (
<p className="text-center text-muted-foreground pt-8">
No user found at {query}
</p>
)
) : results ? (
<Tabs defaultValue="thoughts" className="w-full">
<TabsList>
<TabsTrigger value="thoughts">
Thoughts ({results.thoughts.length})
</TabsTrigger>
<TabsTrigger value="users">
Users ({results.users.length})
</TabsTrigger>
</TabsList>
<TabsContent value="thoughts">
<ThoughtList
thoughts={results.thoughts}
authorDetails={authorDetails}
currentUser={me}
/>
</TabsContent>
<TabsContent value="users">
<UserListCard users={results.users} />
</TabsContent>
</Tabs>
) : (
<p className="text-center text-muted-foreground pt-8">
No results found or an error occurred.
</p>
)}
</main>
</div>
);
}
```
- [ ] **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<String>` used in Task 1, mapped from `DbActor.avatar_url: Option<Url>` 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 ✅

View File

@@ -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<AppState>, AuthUser(uid): AuthUser) -> Result<Json<UserResponse>, 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<AppState>,
Path(username): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
})))
}
pub async fn tag_thoughts_handler(
State(s): State<AppState>,
Path(tag_name): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
})))
}
```
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<AppState>) -> Json<serde_json::Value> {
// 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<UserResponse>` — 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

File diff suppressed because it is too large Load Diff

View File

@@ -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<dyn SearchPort>` 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<Paginated<FeedEntry>, DomainError>;
/// Search users by username or display_name, ranked by trigram similarity.
async fn search_users(
&self,
query: &str,
page: &PageParams,
) -> Result<Paginated<User>, 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<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
async fn search_users(&self, _q: &str, _p: &PageParams) -> Result<Paginated<User>, 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<uuid::Uuid>,
in_reply_to_url: Option<String>,
t_ap_id: Option<String>,
visibility: String,
content_warning: Option<String>,
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
u_ap_id: Option<String>,
inbox_url: Option<String>,
public_key: Option<String>,
private_key: Option<String>,
author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>,
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<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
local: bool,
ap_id: Option<String>,
inbox_url: Option<String>,
public_key: Option<String>,
private_key: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<UserRow> 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<Paginated<FeedEntry>, 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<Paginated<User>, 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<Paginated<FeedEntry>, 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<dyn UserRepository>,
pub thoughts: Arc<dyn ThoughtRepository>,
pub likes: Arc<dyn LikeRepository>,
pub boosts: Arc<dyn BoostRepository>,
pub follows: Arc<dyn FollowRepository>,
pub blocks: Arc<dyn BlockRepository>,
pub tags: Arc<dyn TagRepository>,
pub api_keys: Arc<dyn ApiKeyRepository>,
pub top_friends: Arc<dyn TopFriendRepository>,
pub notifications: Arc<dyn NotificationRepository>,
pub remote_actors: Arc<dyn RemoteActorRepository>,
pub feed: Arc<dyn FeedRepository>,
pub search: Arc<dyn SearchPort>, // NEW
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
}
```
- [ ] **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<AppState>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<SearchQuery>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>();
let users = users_result?.items.into_iter().map(|u| to_user_response(&u)).collect::<Vec<_>>();
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<dyn SearchPort>` 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<FeedEntry>` — matches domain model
- `SearchPort::search_users` → returns `Paginated<User>` — matches domain model
- `PgSearchRepository::new(pool: PgPool)` — consistent with all other repo constructors
- `AppState.search: Arc<dyn SearchPort>` — 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.

View File

@@ -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<String>,
},
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, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
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<EventEnvelope, DomainError>> {
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::<EventPayload>(&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<dyn ThoughtRepository>,
pub notifications: Arc<dyn NotificationRepository>,
}
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<dyn EventPublisher> = 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::...`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<String, DomainError>` 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/`)

View File

@@ -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<String>,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub public_key: String,
pub avatar_url: Option<String>,
pub last_fetched_at: DateTime<Utc>,
// new:
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
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<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
}
```
### 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<Vec<RemoteNote>, 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<Vec<RemoteNote>, 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<dyn ActivityPubRepository>` 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<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>(),
})))
}
```
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<String>,
pub avatar_url: Option<String>,
pub url: String,
// new:
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub attachment: Vec<ProfileField>,
}
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<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` 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(&note.ap_id) {
Ok(u) => u,
Err(_) => continue,
};
// accept_note is idempotent — ignore duplicate errors
let _ = self.ap_repo.accept_note(
&ap_id, &author_id, &note.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 `<RemoteUserProfile>`. 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

View File

@@ -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<String>`
**`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<RemoteActor, DomainError>;
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<ActivityPubService>` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`.
`AppState` gets one new field:
```rust
pub federation: Arc<dyn FederationActionPort>,
```
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<String>,
pub avatar_url: Option<String>,
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)

View File

@@ -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<dyn Port>` 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<String> (None for remote)
Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote),
in_reply_to: Option<ThoughtId | RemoteUrl>, ap_id: Url,
visibility: Public|Followers|Unlisted|Direct,
content_warning: Option<String>, 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 (18)
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<dyn Port>` — no NATS coupling inside them. Worker `main.rs` wires everything together.

View File

@@ -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<String>,
pub avatar_url: Option<String>,
}
```
### 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<Vec<ActorConnectionSummary>, DomainError>;
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
}
```
### New `FederationActionPort` method
```rust
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<ActorConnectionSummary>;
```
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<ActorConnectionSummary>`
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<EventPayload>`, and uniqueness test.
## Worker
`FederationEventService` gains `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>`.
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<dyn RemoteActorConnectionRepository>` 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<typeof ActorConnectionSchema>;
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.