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

7.5 KiB

Remote Actor Connections (Followers/Following) Design

Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution.

Data Flow

  1. User opens the Followers or Following tab on a remote actor profile
  2. Frontend calls GET /federation/actors/{handle}/followers-list?page=1
  3. Backend returns cached data immediately (may be empty on first visit)
  4. If cache is empty OR older than 1 hour: publish FetchActorConnections event fire-and-forget
  5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results
  6. Next visit / tab re-open shows populated data

Domain Changes

New models (domain/src/models/)

connection_type.rs:

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionType {
    Followers,
    Following,
}

impl ConnectionType {
    pub fn as_str(&self) -> &'static str {
        match self { Self::Followers => "followers", Self::Following => "following" }
    }
}

actor_connection_summary.rs:

#[derive(Debug, Clone)]
pub struct ActorConnectionSummary {
    pub url: String,            // AP URL of the connected actor
    pub handle: String,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
}

New DomainEvent variant (domain/src/events.rs)

FetchActorConnections {
    actor_ap_url: String,
    collection_url: String,
    connection_type: String,  // "followers" | "following"
    page: u32,
},

New port (domain/src/ports.rs)

pub trait RemoteActorConnectionRepository: Send + Sync {
    async fn upsert_connections(
        &self,
        actor_url: &str,
        connection_type: &str,
        page: u32,
        actors: &[ActorConnectionSummary],
    ) -> Result<(), DomainError>;

    async fn list_connections(
        &self,
        actor_url: &str,
        connection_type: &str,
        page: u32,
    ) -> Result<Vec<ActorConnectionSummary>, DomainError>;

    async fn connection_page_age(
        &self,
        actor_url: &str,
        connection_type: &str,
        page: u32,
    ) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
}

New FederationActionPort method

async fn resolve_actor_profiles(
    &self,
    urls: Vec<String>,
) -> Vec<ActorConnectionSummary>;

Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged).

Storage

Migration: 006_remote_actor_connections.sql

CREATE TABLE remote_actor_connections (
    id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    actor_url       TEXT        NOT NULL,
    connection_type TEXT        NOT NULL,
    page            INT         NOT NULL,
    connected_actor_url   TEXT  NOT NULL,
    connected_handle      TEXT  NOT NULL,
    connected_display_name TEXT,
    connected_avatar_url   TEXT,
    fetched_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(actor_url, connection_type, page, connected_actor_url)
);
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);

PgRemoteActorConnectionRepository

  • upsert_connections: INSERT ... ON CONFLICT DO UPDATE SET connected_handle=EXCLUDED.connected_handle, connected_display_name=EXCLUDED.connected_display_name, connected_avatar_url=EXCLUDED.connected_avatar_url, fetched_at=NOW()
  • list_connections: SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle
  • connection_page_age: SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3

activitypub-base: resolve_actor_profiles

ActivityPubService implements FederationActionPort::resolve_actor_profiles:

  1. For each URL: spawn tokio::time::timeout(5s, fetch_actor_profile(url))
  2. fetch_actor_profile: GET {url} with Accept: application/activity+json → parse preferred_username, name, icon.url, id
  3. Collect Ok results → return as Vec<ActorConnectionSummary>
  4. Failed/timed-out actors: tracing::warn! and skip

event-payload

Add FetchActorConnections { actor_ap_url, collection_url, connection_type, page } to EventPayload — subject: "federation.fetch_actor_connections". Add to From<&DomainEvent>, TryFrom<EventPayload>, and uniqueness test.

Worker

FederationEventService gains remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>.

Handler for FetchActorConnections { actor_ap_url, collection_url, connection_type, page }:

  1. Fetch collection_url (as AP JSON) → extract orderedItems array as Vec of URL strings
  2. If empty: return Ok(()) — nothing to store
  3. federation_action.resolve_actor_profiles(urls).await — concurrent, partial success OK
  4. remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await
  5. Log: tracing::info!(count = results.len(), "actor connections cached")

Wire remote_actor_connections in worker/src/factory.rs.

AppState + Bootstrap

Add remote_actor_connections: Arc<dyn RemoteActorConnectionRepository> to AppState. Wire PgRemoteActorConnectionRepository in bootstrap/src/factory.rs.

REST Endpoints

GET /federation/actors/{handle}/followers-list?page=1

1. lookup_actor(handle) → get actor_ap_url + followers_url
2. list_connections(actor_ap_url, "followers", page) → cached items
3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget)
4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE }

PAGE_SIZE = 20. has_more tells the frontend whether to show a "next" button.

GET /federation/actors/{handle}/following-list?page=1 — identical, uses following_url and "following".

Response item shape (reuses RemoteActorResponse minus bio/banner/attachment/outbox_url):

{ "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." }

Define as a new ActorConnectionResponse in api-types.

Mount both routes in routes.rs. Add new handler file federation_actors.rs (already exists — add to it).

Frontend

lib/api.ts

export const ActorConnectionSchema = z.object({
  handle: z.string(),
  displayName: z.string().nullable(),
  avatarUrl: z.string().nullable(),
  url: z.string(),
});
export type ActorConnection = z.infer<typeof ActorConnectionSchema>;

const ConnectionPageSchema = z.object({
  items: z.array(ActorConnectionSchema),
  page: z.number(),
  hasMore: z.boolean(),
});

export const getActorFollowers = (handle, page, token) =>
  apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token);

export const getActorFollowing = (handle, page, token) =>
  apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token);

RemoteUserProfile changes

Replace the plain "Followers / Following" link section with two client-side tabs. Each tab:

  • Shows a list of RemoteUserCard components (reuse existing)
  • "Load more" button if hasMore
  • Empty state: "Loading — check back soon."
  • Tab is lazy: only fetches when first opened (not on profile load)

Use the existing RemoteUserCard component — it already handles follow button and linking.

remote-user-profile.tsx note

The component is already a client component ("use client"), so React state for tab selection and paginated data works fine. Each tab fetches via getActorFollowers/getActorFollowing when first activated.