Files
thoughts/docs/superpowers/plans/2026-05-15-actor-connections.md

1206 lines
36 KiB
Markdown

# 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<String>,
pub avatar_url: Option<String>,
}
```
- [ ] **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<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError>;
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
}
```
Then in `FederationActionPort`, add two new methods:
```rust
async fn fetch_actor_urls_from_collection(
&self,
collection_url: &str,
) -> Result<Vec<String>, DomainError>;
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
```
- [ ] **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<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError> {
Ok(vec![])
}
async fn connection_page_age(
&self,
_actor_url: &str,
_connection_type: &str,
_page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, 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<Vec<String>, DomainError> {
Ok(vec![])
}
async fn resolve_actor_profiles(
&self,
_urls: Vec<String>,
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary> {
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<Vec<ActorConnectionSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
connected_actor_url: String,
connected_handle: String,
connected_display_name: Option<String>,
connected_avatar_url: Option<String>,
}
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<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
let row: Option<(Option<chrono::DateTime<chrono::Utc>>,)> = 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<Vec<String>, 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<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
use futures::future;
async fn fetch_one(
url: String,
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
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<EventPayload>` 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<dyn domain::ports::RemoteActorConnectionRepository>,
```
- [ ] **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<dyn domain::ports::RemoteActorConnectionRepository>;
```
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<dyn RemoteActorConnectionRepository>,
```
`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<String>,
pub avatar_url: Option<String>,
pub url: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActorConnectionPageResponse {
pub items: Vec<ActorConnectionResponse>,
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<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
actor_connections_handler(s, handle, "followers", q.page() as u32).await
}
pub async fn actor_following_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, 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<Json<ActorConnectionPageResponse>, 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<typeof ActorConnectionSchema>;
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<Tab>("posts");
const [followers, setFollowers] = useState<ActorConnection[]>([]);
const [following, setFollowing] = useState<ActorConnection[]>([]);
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 (`<div className="col-span-1 lg:col-span-3 space-y-4">...`) with:
```tsx
<div className="col-span-1 lg:col-span-3">
<Tabs defaultValue="posts" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="posts">Posts</TabsTrigger>
<TabsTrigger value="followers">Followers</TabsTrigger>
<TabsTrigger value="following">Following</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="space-y-4 mt-4">
{initialPosts.length > 0 ? (
<ThoughtList
thoughts={initialPosts}
authorDetails={authorDetails}
currentUser={me}
/>
) : (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">
Posts are being fetched check back soon.
</p>
</Card>
)}
</TabsContent>
<TabsContent value="followers" className="mt-4">
{!followersLoaded ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">Loading followers</p>
</Card>
) : followers.length === 0 ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">
No followers cached yet check back soon.
</p>
</Card>
) : (
<div className="space-y-2">
{followers.map((f) => (
<RemoteUserCard key={f.url} actor={f} />
))}
{followersHasMore && (
<button
onClick={() => loadFollowers(followersPage + 1)}
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Load more
</button>
)}
</div>
)}
</TabsContent>
<TabsContent value="following" className="mt-4">
{!followingLoaded ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">Loading following</p>
</Card>
) : following.length === 0 ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">
No following cached yet check back soon.
</p>
</Card>
) : (
<div className="space-y-2">
{following.map((f) => (
<RemoteUserCard key={f.url} actor={f} />
))}
{followingHasMore && (
<button
onClick={() => loadFollowing(followingPage + 1)}
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Load more
</button>
)}
</div>
)}
</TabsContent>
</Tabs>
</div>
```
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 ✅