From 23501f52037e3b9e0b1275091f80d43b55540b30 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 00:22:03 +0200 Subject: [PATCH] docs: remote actor connections implementation plan --- .../plans/2026-05-15-actor-connections.md | 1205 +++++++++++++++++ 1 file changed, 1205 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-actor-connections.md diff --git a/docs/superpowers/plans/2026-05-15-actor-connections.md b/docs/superpowers/plans/2026-05-15-actor-connections.md new file mode 100644 index 0000000..43e2bc4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-actor-connections.md @@ -0,0 +1,1205 @@ +# Remote Actor Connections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a remote actor's followers and following as browseable lists within the thoughts UI, backed by a worker cache with concurrent AP profile resolution. + +**Architecture:** New domain models (`ConnectionType`, `ActorConnectionSummary`) + new port (`RemoteActorConnectionRepository`) + new `FederationActionPort` methods. REST endpoints return cached data and fire a `FetchActorConnections` event fire-and-forget. Worker fetches the AP collection, concurrently resolves each actor URL to a profile (5s timeout per actor, partial failures silently skipped), and upserts results. Frontend adds Followers/Following tabs to `RemoteUserProfile` using existing `RemoteUserCard`. + +**Tech Stack:** Rust (axum, sqlx, tokio, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod. + +--- + +## File Map + +| Action | Path | Change | +|--------|------|--------| +| Create | `crates/domain/src/models/connection_type.rs` | `ConnectionType` enum | +| Create | `crates/domain/src/models/actor_connection_summary.rs` | `ActorConnectionSummary` struct | +| Modify | `crates/domain/src/models/mod.rs` | expose new modules | +| Modify | `crates/domain/src/events.rs` | `FetchActorConnections` variant | +| Modify | `crates/domain/src/ports.rs` | `RemoteActorConnectionRepository` port; 2 new `FederationActionPort` methods | +| Modify | `crates/domain/src/testing.rs` | stubs + test | +| Create | `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` | new table | +| Create | `crates/adapters/postgres/src/remote_actor_connections.rs` | postgres impl | +| Modify | `crates/adapters/postgres/src/lib.rs` | expose module, export type | +| Modify | `crates/adapters/activitypub-base/src/service.rs` | impl 2 new port methods | +| Modify | `crates/adapters/event-payload/src/lib.rs` | `FetchActorConnections` variant | +| Modify | `crates/application/src/services/federation_event.rs` | new dep + handler | +| Modify | `crates/worker/src/factory.rs` | wire `remote_actor_connections` | +| Modify | `crates/api-types/src/responses.rs` | `ActorConnectionResponse` | +| Modify | `crates/presentation/src/state.rs` | add `remote_actor_connections` field | +| Modify | `crates/bootstrap/src/factory.rs` | wire new repo | +| Modify | `crates/presentation/src/handlers/federation_actors.rs` | 2 new handlers | +| Modify | `crates/presentation/src/handlers/*.rs` (tests) | add `remote_actor_connections` to `make_state()` | +| Modify | `crates/presentation/src/routes.rs` | mount 2 new routes | +| Modify | `thoughts-frontend/lib/api.ts` | new schema + 2 fetch functions | +| Modify | `thoughts-frontend/components/remote-user-profile.tsx` | replace links with tabs | + +--- + +## Task 1: Domain — models, port, event, stubs + +**Files:** +- Create: `crates/domain/src/models/connection_type.rs` +- Create: `crates/domain/src/models/actor_connection_summary.rs` +- Modify: `crates/domain/src/models/mod.rs` +- Modify: `crates/domain/src/events.rs` +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/domain/src/testing.rs` + +- [ ] **Step 1: Create `connection_type.rs`** + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionType { + Followers, + Following, +} + +impl ConnectionType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Followers => "followers", + Self::Following => "following", + } + } +} +``` + +- [ ] **Step 2: Create `actor_connection_summary.rs`** + +```rust +#[derive(Debug, Clone)] +pub struct ActorConnectionSummary { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} +``` + +- [ ] **Step 3: Register in `models/mod.rs`** + +Add: +```rust +pub mod actor_connection_summary; +pub mod connection_type; +``` + +- [ ] **Step 4: Add `FetchActorConnections` to `DomainEvent`** + +Read `crates/domain/src/events.rs`. Add before the closing brace: +```rust +FetchActorConnections { + actor_ap_url: String, + collection_url: String, + connection_type: String, + page: u32, +}, +``` + +- [ ] **Step 5: Write failing domain test** + +At the bottom of `crates/domain/src/testing.rs`, in the `federation_port_tests` module, add: +```rust +#[tokio::test] +async fn test_store_resolve_actor_profiles_returns_empty() { + let store = TestStore::default(); + let result = store.resolve_actor_profiles(vec!["https://example.com/users/alice".into()]).await; + assert!(result.is_empty()); +} + +#[tokio::test] +async fn test_store_fetch_collection_urls_returns_empty() { + let store = TestStore::default(); + let urls = store.fetch_actor_urls_from_collection("https://example.com/users/alice/followers").await.unwrap(); + assert!(urls.is_empty()); +} +``` + +- [ ] **Step 6: Run to confirm compile failure** + +```bash +cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 +``` + +Expected: compile error — new port methods and `RemoteActorConnectionRepository` not defined. + +- [ ] **Step 7: Add `RemoteActorConnectionRepository` to `ports.rs`** + +Read `crates/domain/src/ports.rs`. Add after `RemoteActorRepository`: + +```rust +#[async_trait] +pub trait RemoteActorConnectionRepository: Send + Sync { + async fn upsert_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], + ) -> Result<(), DomainError>; + + async fn list_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result, DomainError>; + + async fn connection_page_age( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result>, DomainError>; +} +``` + +Then in `FederationActionPort`, add two new methods: +```rust +async fn fetch_actor_urls_from_collection( + &self, + collection_url: &str, +) -> Result, DomainError>; + +async fn resolve_actor_profiles( + &self, + urls: Vec, +) -> Vec; +``` + +- [ ] **Step 8: Add stubs to `TestStore`** + +In `crates/domain/src/testing.rs`, add after the existing `impl FederationActionPort for TestStore` block: + +```rust +#[async_trait] +impl RemoteActorConnectionRepository for TestStore { + async fn upsert_connections( + &self, + _actor_url: &str, + _connection_type: &str, + _page: u32, + _actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn list_connections( + &self, + _actor_url: &str, + _connection_type: &str, + _page: u32, + ) -> Result, DomainError> { + Ok(vec![]) + } + + async fn connection_page_age( + &self, + _actor_url: &str, + _connection_type: &str, + _page: u32, + ) -> Result>, DomainError> { + Ok(None) + } +} +``` + +Inside `impl FederationActionPort for TestStore`, add the two new methods: +```rust +async fn fetch_actor_urls_from_collection( + &self, + _collection_url: &str, +) -> Result, DomainError> { + Ok(vec![]) +} + +async fn resolve_actor_profiles( + &self, + _urls: Vec, +) -> Vec { + vec![] +} +``` + +- [ ] **Step 9: Run tests to confirm pass** + +```bash +cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 10: Full compile check** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5 +``` + +- [ ] **Step 11: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add crates/domain/src/models/connection_type.rs \ + crates/domain/src/models/actor_connection_summary.rs \ + crates/domain/src/models/mod.rs \ + crates/domain/src/events.rs \ + crates/domain/src/ports.rs \ + crates/domain/src/testing.rs +git commit -m "feat(domain): ActorConnectionSummary, ConnectionType, RemoteActorConnectionRepository, FetchActorConnections event" +``` + +--- + +## Task 2: PostgreSQL adapter — migration + repository + +**Files:** +- Create: `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` +- Create: `crates/adapters/postgres/src/remote_actor_connections.rs` +- Modify: `crates/adapters/postgres/src/lib.rs` + +- [ ] **Step 1: Create migration** + +Create `crates/adapters/postgres/migrations/006_remote_actor_connections.sql`: + +```sql +CREATE TABLE remote_actor_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_url TEXT NOT NULL, + connection_type TEXT NOT NULL, + page INT NOT NULL, + connected_actor_url TEXT NOT NULL, + connected_handle TEXT NOT NULL, + connected_display_name TEXT, + connected_avatar_url TEXT, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(actor_url, connection_type, page, connected_actor_url) +); +CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at); +``` + +- [ ] **Step 2: Create `remote_actor_connections.rs`** + +Create `crates/adapters/postgres/src/remote_actor_connections.rs`: + +```rust +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::actor_connection_summary::ActorConnectionSummary, + ports::RemoteActorConnectionRepository, +}; +use sqlx::PgPool; + +pub struct PgRemoteActorConnectionRepository { + pool: PgPool, +} + +impl PgRemoteActorConnectionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository { + async fn upsert_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + actors: &[ActorConnectionSummary], + ) -> Result<(), DomainError> { + for actor in actors { + sqlx::query( + "INSERT INTO remote_actor_connections + (actor_url, connection_type, page, connected_actor_url, + connected_handle, connected_display_name, connected_avatar_url, fetched_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT(actor_url, connection_type, page, connected_actor_url) + DO UPDATE SET + connected_handle = EXCLUDED.connected_handle, + connected_display_name = EXCLUDED.connected_display_name, + connected_avatar_url = EXCLUDED.connected_avatar_url, + fetched_at = NOW()", + ) + .bind(actor_url) + .bind(connection_type) + .bind(page as i32) + .bind(&actor.url) + .bind(&actor.handle) + .bind(&actor.display_name) + .bind(&actor.avatar_url) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + } + Ok(()) + } + + async fn list_connections( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + connected_actor_url: String, + connected_handle: String, + connected_display_name: Option, + connected_avatar_url: Option, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url + FROM remote_actor_connections + WHERE actor_url = $1 AND connection_type = $2 AND page = $3 + ORDER BY connected_handle", + ) + .bind(actor_url) + .bind(connection_type) + .bind(page as i32) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + Ok(rows + .into_iter() + .map(|r| ActorConnectionSummary { + url: r.connected_actor_url, + handle: r.connected_handle, + display_name: r.connected_display_name, + avatar_url: r.connected_avatar_url, + }) + .collect()) + } + + async fn connection_page_age( + &self, + actor_url: &str, + connection_type: &str, + page: u32, + ) -> Result>, DomainError> { + let row: Option<(Option>,)> = sqlx::query_as( + "SELECT MAX(fetched_at) FROM remote_actor_connections + WHERE actor_url = $1 AND connection_type = $2 AND page = $3", + ) + .bind(actor_url) + .bind(connection_type) + .bind(page as i32) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + Ok(row.and_then(|(ts,)| ts)) + } +} +``` + +- [ ] **Step 3: Expose in `postgres/src/lib.rs`** + +Read `crates/adapters/postgres/src/lib.rs`. Add: +```rust +pub mod remote_actor_connections; +``` + +- [ ] **Step 4: Compile check** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p postgres 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add crates/adapters/postgres/migrations/006_remote_actor_connections.sql \ + crates/adapters/postgres/src/remote_actor_connections.rs \ + crates/adapters/postgres/src/lib.rs +git commit -m "feat(postgres): remote_actor_connections table + PgRemoteActorConnectionRepository" +``` + +--- + +## Task 3: activitypub-base — implement `fetch_actor_urls_from_collection` + `resolve_actor_profiles` + +**Files:** +- Modify: `crates/adapters/activitypub-base/src/service.rs` + +- [ ] **Step 1: Confirm compile failure** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 +``` + +Expected: error — `fetch_actor_urls_from_collection` and `resolve_actor_profiles` not implemented. + +- [ ] **Step 2: Implement both methods in the `FederationActionPort` impl block** + +Read the file. At the bottom of `impl domain::ports::FederationActionPort for ActivityPubService`, after `fetch_outbox_page`, add: + +```rust +async fn fetch_actor_urls_from_collection( + &self, + collection_url: &str, +) -> Result, domain::errors::DomainError> { + let resp: serde_json::Value = reqwest::Client::new() + .get(collection_url) + .header("Accept", "application/activity+json, application/ld+json") + .send() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))? + .json() + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + + let empty = vec![]; + let items = resp["orderedItems"].as_array().unwrap_or(&empty); + Ok(items + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()) +} + +async fn resolve_actor_profiles( + &self, + urls: Vec, +) -> Vec { + use futures::future; + + async fn fetch_one( + url: String, + ) -> Option { + let resp: serde_json::Value = tokio::time::timeout( + std::time::Duration::from_secs(5), + reqwest::Client::new() + .get(&url) + .header("Accept", "application/activity+json") + .send(), + ) + .await + .ok()? + .ok()? + .json() + .await + .ok()?; + + let ap_url = resp["id"].as_str()?.to_string(); + let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string(); + let domain_str = url::Url::parse(&ap_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + let handle = format!("{}@{}", preferred_username, domain_str); + let display_name = resp["name"].as_str().map(|s| s.to_string()); + let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string()); + + Some(domain::models::actor_connection_summary::ActorConnectionSummary { + url: ap_url, + handle, + display_name, + avatar_url, + }) + } + + let futs: Vec<_> = urls.into_iter().map(fetch_one).collect(); + let results = future::join_all(futs).await; + + results + .into_iter() + .filter_map(|r| { + if r.is_none() { + tracing::warn!("failed to resolve actor profile (timeout or parse error)"); + } + r + }) + .collect() +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 +``` + +- [ ] **Step 4: Run all tests** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add crates/adapters/activitypub-base/src/service.rs +git commit -m "feat(activitypub-base): impl fetch_actor_urls_from_collection + resolve_actor_profiles (concurrent, 5s timeout)" +``` + +--- + +## Task 4: event-payload — `FetchActorConnections` + +**Files:** +- Modify: `crates/adapters/event-payload/src/lib.rs` + +- [ ] **Step 1: Add variant to `EventPayload` enum** + +Read the file. Add at the end of the enum: +```rust +FetchActorConnections { + actor_ap_url: String, + collection_url: String, + connection_type: String, + page: u32, +}, +``` + +- [ ] **Step 2: Add subject** + +In `subject()`: +```rust +Self::FetchActorConnections { .. } => "federation.fetch_actor_connections", +``` + +- [ ] **Step 3: Add `From<&DomainEvent>` arm** + +```rust +DomainEvent::FetchActorConnections { + actor_ap_url, + collection_url, + connection_type, + page, +} => Self::FetchActorConnections { + actor_ap_url: actor_ap_url.clone(), + collection_url: collection_url.clone(), + connection_type: connection_type.clone(), + page: *page, +}, +``` + +- [ ] **Step 4: Add `TryFrom` arm** + +```rust +EventPayload::FetchActorConnections { + actor_ap_url, + collection_url, + connection_type, + page, +} => DomainEvent::FetchActorConnections { + actor_ap_url, + collection_url, + connection_type, + page, +}, +``` + +- [ ] **Step 5: Add to uniqueness test sample array** + +```rust +EventPayload::FetchActorConnections { + actor_ap_url: "https://mastodon.social/users/alice".into(), + collection_url: "https://mastodon.social/users/alice/followers".into(), + connection_type: "followers".into(), + page: 1, +}, +``` + +- [ ] **Step 6: Test** + +```bash +cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -5 +``` + +Expected: all pass (uniqueness test includes new variant). + +- [ ] **Step 7: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add crates/adapters/event-payload/src/lib.rs +git commit -m "feat(event-payload): FetchActorConnections event" +``` + +--- + +## Task 5: Worker — handle `FetchActorConnections` + wire repo + +**Files:** +- Modify: `crates/application/src/services/federation_event.rs` +- Modify: `crates/worker/src/factory.rs` + +- [ ] **Step 1: Add `remote_actor_connections` to `FederationEventService`** + +Read `crates/application/src/services/federation_event.rs`. Add to the struct: +```rust +pub remote_actor_connections: Arc, +``` + +- [ ] **Step 2: Handle `FetchActorConnections` in `process()`** + +Before the `_ => Ok(())` arm, add: + +```rust +DomainEvent::FetchActorConnections { + actor_ap_url, + collection_url, + connection_type, + page, +} => { + let urls = match self + .federation_action + .fetch_actor_urls_from_collection(collection_url) + .await + { + Ok(u) => u, + Err(e) => { + tracing::warn!( + collection_url, + error = %e, + "failed to fetch actor connections collection" + ); + return Ok(()); + } + }; + + if urls.is_empty() { + return Ok(()); + } + + let summaries = self + .federation_action + .resolve_actor_profiles(urls) + .await; + + if summaries.is_empty() { + return Ok(()); + } + + tracing::info!( + count = summaries.len(), + connection_type, + actor = actor_ap_url, + "caching actor connections" + ); + + self.remote_actor_connections + .upsert_connections(actor_ap_url, connection_type, *page, &summaries) + .await?; + + Ok(()) +} +``` + +- [ ] **Step 3: Add test** + +In the `#[cfg(test)]` block, add `remote_actor_connections: Arc::new(store.clone())` to the `svc()` helper, then add: + +```rust +#[tokio::test] +async fn fetch_actor_connections_is_noop_when_collection_empty() { + let store = TestStore::default(); + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::FetchActorConnections { + actor_ap_url: "https://mastodon.social/users/alice".into(), + collection_url: "https://mastodon.social/users/alice/followers".into(), + connection_type: "followers".into(), + page: 1, + }) + .await + .unwrap(); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -10 +``` + +Expected: all pass. + +- [ ] **Step 5: Wire `remote_actor_connections` in `worker/src/factory.rs`** + +Read the file. Add import: +```rust +use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; +``` + +Add the repo: +```rust +let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())) + as Arc; +``` + +Add to `FederationEventService` construction: +```rust +remote_actor_connections: actor_connections, +``` + +- [ ] **Step 6: Compile check** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p worker 2>&1 | tail -10 +``` + +- [ ] **Step 7: Run all tests** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 +``` + +- [ ] **Step 8: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add crates/application/src/services/federation_event.rs \ + crates/worker/src/factory.rs +git commit -m "feat(worker): handle FetchActorConnections — resolve and cache remote actor connections" +``` + +--- + +## Task 6: AppState + bootstrap + REST endpoints + +**Files:** +- Modify: `crates/presentation/src/state.rs` +- Modify: `crates/bootstrap/src/factory.rs` +- Modify: `crates/api-types/src/responses.rs` +- Modify: `crates/presentation/src/handlers/federation_actors.rs` +- Modify: `crates/presentation/src/handlers/` (test make_state() helpers) +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Step 1: Add `remote_actor_connections` to `AppState`** + +Read `crates/presentation/src/state.rs`. Add field: +```rust +pub remote_actor_connections: Arc, +``` + +`RemoteActorConnectionRepository` is in `domain::ports::*`, already imported. + +- [ ] **Step 2: Wire in `bootstrap/src/factory.rs`** + +Read the file. Add import: +```rust +use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; +``` + +Add to `AppState { ... }`: +```rust +remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), +``` + +- [ ] **Step 3: Add `ActorConnectionResponse` to api-types** + +Read `crates/api-types/src/responses.rs`. Add: + +```rust +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActorConnectionResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActorConnectionPageResponse { + pub items: Vec, + pub page: u32, + pub has_more: bool, +} +``` + +- [ ] **Step 4: Fix broken test `make_state()` helpers** + +Find all handlers with `make_state()` that construct `AppState` — they will now be missing `remote_actor_connections`. Run: +```bash +cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "missing field" | head -5 +``` +For each affected test module, add `remote_actor_connections: store.clone()` to the `AppState` construction. + +- [ ] **Step 5: Add two new handlers to `federation_actors.rs`** + +Read the file. Add imports at the top: +```rust +use api_types::responses::{ActorConnectionPageResponse, ActorConnectionResponse}; +use domain::events::DomainEvent; +``` + +Add after `remote_actor_posts_handler`: + +```rust +const CACHE_TTL_SECS: i64 = 3600; + +pub async fn actor_followers_handler( + State(s): State, + Path(handle): Path, + Query(q): Query, +) -> Result, ApiError> { + actor_connections_handler(s, handle, "followers", q.page() as u32).await +} + +pub async fn actor_following_handler( + State(s): State, + Path(handle): Path, + Query(q): Query, +) -> Result, ApiError> { + actor_connections_handler(s, handle, "following", q.page() as u32).await +} + +async fn actor_connections_handler( + s: AppState, + handle: String, + connection_type: &str, + page: u32, +) -> Result, ApiError> { + const PAGE_SIZE: usize = 20; + + let actor = s.federation.lookup_actor(&handle).await?; + + let collection_url = match connection_type { + "followers" => actor + .followers_url + .ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?, + _ => actor + .following_url + .ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?, + }; + + let items = s + .remote_actor_connections + .list_connections(&actor.url, connection_type, page) + .await?; + + // Fire fetch if cache is missing or stale + let stale = match s + .remote_actor_connections + .connection_page_age(&actor.url, connection_type, page) + .await? + { + None => true, + Some(age) => { + chrono::Utc::now() + .signed_duration_since(age) + .num_seconds() + > CACHE_TTL_SECS + } + }; + + if stale { + let _ = s + .events + .publish(&DomainEvent::FetchActorConnections { + actor_ap_url: actor.url.clone(), + collection_url, + connection_type: connection_type.to_string(), + page, + }) + .await; + } + + let has_more = items.len() >= PAGE_SIZE; + Ok(Json(ActorConnectionPageResponse { + items: items + .into_iter() + .map(|a| ActorConnectionResponse { + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + url: a.url, + }) + .collect(), + page, + has_more, + })) +} +``` + +- [ ] **Step 6: Mount routes** + +Read `crates/presentation/src/routes.rs`. After the existing `/federation/actors/{handle}/posts` route, add: + +```rust +.route( + "/federation/actors/{handle}/followers-list", + get(federation_actors::actor_followers_handler), +) +.route( + "/federation/actors/{handle}/following-list", + get(federation_actors::actor_following_handler), +) +``` + +- [ ] **Step 7: Run all tests** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add crates/presentation/src/state.rs \ + crates/bootstrap/src/factory.rs \ + crates/api-types/src/responses.rs \ + crates/presentation/src/handlers/federation_actors.rs \ + crates/presentation/src/routes.rs +# Also add any handler files with updated make_state() +git commit -m "feat(presentation): followers/following list endpoints for remote actors" +``` + +--- + +## Task 7: Frontend — API + tabs in `RemoteUserProfile` + +**Files:** +- Modify: `thoughts-frontend/lib/api.ts` +- Modify: `thoughts-frontend/components/remote-user-profile.tsx` + +- [ ] **Step 1: Add schema + fetch functions to `api.ts`** + +Read the file. After `getActorFollowing`/`getActorFollowers` (or after `getRemoteActorPosts`), add: + +```typescript +export const ActorConnectionSchema = z.object({ + handle: z.string(), + displayName: z.string().nullable(), + avatarUrl: z.string().nullable(), + url: z.string(), +}); +export type ActorConnection = z.infer; + +const ActorConnectionPageSchema = z.object({ + items: z.array(ActorConnectionSchema), + page: z.number(), + hasMore: z.boolean(), +}); + +export const getActorFollowers = ( + handle: string, + page: number, + token: string | null +) => + apiFetch( + `/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, + {}, + ActorConnectionPageSchema, + token + ); + +export const getActorFollowing = ( + handle: string, + page: number, + token: string | null +) => + apiFetch( + `/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, + {}, + ActorConnectionPageSchema, + token + ); +``` + +- [ ] **Step 2: Update `remote-user-profile.tsx`** + +Read the full file. Replace the existing followers/following links section AND add tab state + lazy loading. The component is already `"use client"`. + +Add imports at the top: +```typescript +import { getActorFollowers, getActorFollowing, ActorConnection } from "@/lib/api"; +import { RemoteUserCard } from "@/components/remote-user-card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +``` + +Add state inside the component (after existing state): +```typescript +type Tab = "posts" | "followers" | "following"; +const [activeTab, setActiveTab] = useState("posts"); +const [followers, setFollowers] = useState([]); +const [following, setFollowing] = useState([]); +const [followersPage, setFollowersPage] = useState(1); +const [followingPage, setFollowingPage] = useState(1); +const [followersHasMore, setFollowersHasMore] = useState(false); +const [followingHasMore, setFollowingHasMore] = useState(false); +const [followersLoaded, setFollowersLoaded] = useState(false); +const [followingLoaded, setFollowingLoaded] = useState(false); +``` + +Add tab handlers: +```typescript +const loadFollowers = async (page: number) => { + const result = await getActorFollowers(actor.handle, page, token).catch(() => null); + if (!result) return; + setFollowers((prev) => page === 1 ? result.items : [...prev, ...result.items]); + setFollowersHasMore(result.hasMore); + setFollowersLoaded(true); + setFollowersPage(page); +}; + +const loadFollowing = async (page: number) => { + const result = await getActorFollowing(actor.handle, page, token).catch(() => null); + if (!result) return; + setFollowing((prev) => page === 1 ? result.items : [...prev, ...result.items]); + setFollowingHasMore(result.hasMore); + setFollowingLoaded(true); + setFollowingPage(page); +}; + +const handleTabChange = (tab: string) => { + setActiveTab(tab as Tab); + if (tab === "followers" && !followersLoaded) loadFollowers(1); + if (tab === "following" && !followingLoaded) loadFollowing(1); +}; +``` + +Replace the posts section (`
...`) with: + +```tsx +
+ + + Posts + Followers + Following + + + + {initialPosts.length > 0 ? ( + + ) : ( + +

+ Posts are being fetched — check back soon. +

+
+ )} +
+ + + {!followersLoaded ? ( + +

Loading followers…

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

+ No followers cached yet — check back soon. +

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

Loading following…

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

+ No following cached yet — check back soon. +

+
+ ) : ( +
+ {following.map((f) => ( + + ))} + {followingHasMore && ( + + )} +
+ )} +
+
+
+``` + +Also remove the old `{(actor.followersUrl || actor.followingUrl) && ...}` plain links section from the sidebar — replaced by tabs. + +- [ ] **Step 3: Type-check** + +```bash +cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -10 +``` + +Fix any type errors. Common issue: `RemoteUserCard` expects `RemoteActor` but we're passing `ActorConnection` — both have the same shape (`handle`, `displayName`, `avatarUrl`, `url`) so you may need a cast or to widen the prop type on `RemoteUserCard`. + +If `RemoteUserCard` is typed as `actor: RemoteActor`, change its prop to `actor: { handle: string; displayName: string | null; avatarUrl: string | null; url: string }` or union type. Alternatively, cast: `actor={f as RemoteActor}`. + +- [ ] **Step 4: Commit** + +```bash +cd /mnt/drive/dev/thoughts +git add thoughts-frontend/lib/api.ts \ + thoughts-frontend/components/remote-user-profile.tsx +git commit -m "feat(frontend): followers/following tabs on remote actor profile with lazy loading + pagination" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `ConnectionType` enum — Task 1 +- ✅ `ActorConnectionSummary` model — Task 1 +- ✅ `RemoteActorConnectionRepository` port — Task 1 +- ✅ `fetch_actor_urls_from_collection` on `FederationActionPort` — Tasks 1 + 3 +- ✅ `resolve_actor_profiles` on `FederationActionPort` (concurrent, 5s timeout, partial) — Tasks 1 + 3 +- ✅ `FetchActorConnections` domain event — Task 1 +- ✅ Migration + `PgRemoteActorConnectionRepository` — Task 2 +- ✅ activitypub-base implements both new methods — Task 3 +- ✅ event-payload wired — Task 4 +- ✅ Worker handles event (fetch collection → resolve profiles → upsert) — Task 5 +- ✅ 1-hour TTL cache logic in endpoint — Task 6 +- ✅ `AppState` + bootstrap wired — Task 6 +- ✅ `ActorConnectionResponse` + `ActorConnectionPageResponse` — Task 6 +- ✅ Two REST endpoints + routes — Task 6 +- ✅ Frontend: schema, fetch fns, tabs with lazy load + pagination — Task 7 +- ✅ Failure handling: partial resolution, warn log, skip — Task 3 + +**Placeholder scan:** None found. + +**Type consistency:** +- `ActorConnectionSummary.url` (domain) → `ActorConnectionResponse.url` (api-types) → `ActorConnection.url` (frontend schema) ✅ +- `connection_type: &str` in port matches `connection_type: String` in event (converted via `.as_str()` when needed) ✅ +- `page: u32` in port, event, endpoint, frontend ✅ +- `RemoteUserCard` prop type — noted in Task 7 step 3 ✅