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

36 KiB

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

#[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
#[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:

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:

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:

#[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
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:

#[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:

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:

#[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:

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
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
cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5
  • Step 11: Commit
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:

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:

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:

pub mod remote_actor_connections;
  • Step 4: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p postgres 2>&1 | tail -10

Expected: no errors.

  • Step 5: Commit
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

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:

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
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
  • Step 4: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5

Expected: all pass.

  • Step 5: Commit
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:

FetchActorConnections {
    actor_ap_url: String,
    collection_url: String,
    connection_type: String,
    page: u32,
},
  • Step 2: Add subject

In subject():

Self::FetchActorConnections { .. } => "federation.fetch_actor_connections",
  • Step 3: Add From<&DomainEvent> arm
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
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
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
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
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:

pub remote_actor_connections: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
  • Step 2: Handle FetchActorConnections in process()

Before the _ => Ok(()) arm, add:

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:

#[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
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:

use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;

Add the repo:

let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()))
    as Arc<dyn domain::ports::RemoteActorConnectionRepository>;

Add to FederationEventService construction:

remote_actor_connections: actor_connections,
  • Step 6: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p worker 2>&1 | tail -10
  • Step 7: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
  • Step 8: Commit
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:

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:

use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;

Add to AppState { ... }:

remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
  • Step 3: Add ActorConnectionResponse to api-types

Read crates/api-types/src/responses.rs. Add:

#[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:

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:

use api_types::responses::{ActorConnectionPageResponse, ActorConnectionResponse};
use domain::events::DomainEvent;

Add after remote_actor_posts_handler:

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:

.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
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5

Expected: all pass.

  • Step 8: Commit
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:

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:

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):

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:

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:

<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
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
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