Files
thoughts/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md

3.0 KiB

Remote Actor Search & Follow

Allows local users to search for and follow users on other ActivityPub instances (e.g. @user@mastodon.social) directly from the existing search page.

Architecture

Approach A: new FederationActionPort domain trait + dedicated /federation/* REST endpoints. Keeps hexagonal arch intact — presentation has no dep on activitypub-base.

Domain changes

domain/src/models/remote_actor.rs — add avatar_url: Option<String>

domain/src/errors.rs — add ExternalService(String) variant

domain/src/ports.rs — new trait:

pub trait FederationActionPort: Send + Sync {
    async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
    async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
}

activitypub-base impl

impl domain::ports::FederationActionPort for ActivityPubService in service.rs:

  • lookup_actor: calls webfinger_resolve_actor(handle, &data) → maps DbActor to domain::RemoteActor
  • follow_remote: delegates to existing self.follow(local_user_id.inner(), handle) (already handles WebFinger + Follow activity + federation DB record)

Bootstrap refactor

factory.rs currently builds FederationData + ApFederationConfig directly. Switch to ActivityPubService::new(...) which creates both internally. Infrastructure holds Arc<ActivityPubService> instead of ApFederationConfig. main.rs uses infra.ap_service.federation_config().middleware().

AppState gets one new field:

pub federation: Arc<dyn FederationActionPort>,

Wired to Arc::clone(&ap_service) in factory.

REST endpoints

api-types/src/responses.rs — new:

pub struct RemoteActorResponse {
    pub handle: String,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub url: String,
}

presentation/src/handlers/federation.rs (new file):

Method Path Auth Body Response
GET /federation/lookup?handle=@user@instance.tld none RemoteActorResponse
POST /federation/follow bearer {"handle":"@user@instance.tld"} 204

Mounted in routes.rs under /federation.

Error mapping: DomainError::ExternalService → 502, DomainError::NotFound → 404.

Frontend

lib/api.ts:

  • RemoteActorSchema + RemoteActor type
  • lookupRemoteActor(handle, token)GET /federation/lookup?handle=...
  • followRemoteUser(handle, token)POST /federation/follow

app/search/page.tsx:

  • Detect @user@instance.tld via regex /^@[\w.-]+@[\w.-]+\.\w+$/
  • If matches: call lookupRemoteActor in parallel with local search
  • Pass remote actor result to component; show in Users tab above local results

components/remote-user-card.tsx (new client component):

  • Displays avatar, handle, display name
  • Follow button calls followRemoteUser(handle, token)
  • No unfollow needed for MVP (remote following status not tracked locally)