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

9.3 KiB

Remote Actor Profile Design

Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker.

Data Flow

  1. User navigates to /users/@gabrielkaszewski@mastodon.social
  2. Frontend detects @user@domain format, calls in parallel:
    • GET /users/lookup?handle=@user@instance → enriched profile metadata
    • GET /federation/actors/{handle}/posts?page=1 → cached posts (empty on first visit)
  3. Posts endpoint: looks up interned local UserId, queries feed.user_feed, then publishes DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } fire-and-forget
  4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via ap_repo.accept_note
  5. On next visit/refresh posts are populated

Domain Changes

Extend domain/src/models/remote_actor.rs

Add fields:

pub struct RemoteActor {
    pub url: String,
    pub handle: String,
    pub display_name: Option<String>,
    pub inbox_url: String,
    pub shared_inbox_url: Option<String>,
    pub public_key: String,
    pub avatar_url: Option<String>,
    pub last_fetched_at: DateTime<Utc>,
    // new:
    pub bio: Option<String>,
    pub banner_url: Option<String>,
    pub also_known_as: Option<String>,
    pub outbox_url: Option<String>,
    pub attachment: Vec<(String, String)>,  // (name, value)
}

New domain/src/models/remote_note.rs

pub struct RemoteNote {
    pub ap_id: String,
    pub content: String,
    pub published: chrono::DateTime<chrono::Utc>,
    pub sensitive: bool,
    pub content_warning: Option<String>,
}

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

FetchRemoteActorPosts {
    actor_ap_url: String,
    outbox_url: String,
}

New FederationActionPort method (domain/src/ports.rs)

async fn fetch_outbox_page(
    &self,
    outbox_url: &str,
    page: u32,
) -> Result<Vec<RemoteNote>, DomainError>;

TestStore stub returns Ok(vec![]).

activitypub-base Implementation

lookup_actor — populate new RemoteActor fields

Map from DbActor:

bio: actor.bio.clone(),
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor.also_known_as.clone(),
outbox_url: Some(actor.outbox_url.to_string()),
attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(),

fetch_outbox_page impl on ActivityPubService

async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<RemoteNote>, DomainError> {
    let url = format!("{}?page={}", outbox_url, page);
    let resp: serde_json::Value = reqwest::Client::new()
        .get(&url)
        .header("Accept", "application/activity+json, application/ld+json")
        .send().await
        .map_err(|e| DomainError::ExternalService(e.to_string()))?
        .json().await
        .map_err(|e| DomainError::ExternalService(e.to_string()))?;

    let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
    Ok(items.iter().filter_map(|item| {
        // Items are Create activities or Notes directly
        let note = if item["type"].as_str() == Some("Create") {
            &item["object"]
        } else if item["type"].as_str() == Some("Note") {
            item
        } else {
            return None;
        };
        // Only public notes
        let to = note["to"].as_array()?;
        let is_public = to.iter().any(|t| {
            t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")
        });
        if !is_public { return None; }
        Some(RemoteNote {
            ap_id: note["id"].as_str()?.to_string(),
            content: note["content"].as_str().unwrap_or("").to_string(),
            published: chrono::DateTime::parse_from_rfc3339(
                note["published"].as_str()?
            ).ok()?.with_timezone(&chrono::Utc),
            sensitive: note["sensitive"].as_bool().unwrap_or(false),
            content_warning: note["summary"].as_str().map(|s| s.to_string()),
        })
    }).collect())
}

AppState + Bootstrap

Add ap_repo: Arc<dyn ActivityPubRepository> to presentation/src/state.rs.

Wire in bootstrap/src/factory.rs:

ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),

event-payload

Add to EventPayload enum:

FetchRemoteActorPosts {
    actor_ap_url: String,
    outbox_url: String,
}

Add subject ("fetch_remote_actor_posts"), mapping from/to DomainEvent, and a sample in the uniqueness test.

REST Endpoint

GET /federation/actors/{handle}/posts?page=1 (new handler in presentation/src/handlers/federation_actors.rs):

pub async fn remote_actor_posts_handler(
    State(s): State<AppState>,
    Path(handle): Path<String>,
    Query(q): Query<PaginationQuery>,
    OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
    let actor = s.federation.lookup_actor(&handle).await?;
    let ap_url = url::Url::parse(&actor.url)
        .map_err(|e| ApiError::BadRequest(e.to_string()))?;

    // Get or create interned local UserId for this remote actor
    let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
        Some(id) => id,
        None => s.ap_repo.intern_remote_actor(&ap_url).await?,
    };

    // Return cached posts
    let page = PageParams { page: q.page(), per_page: q.per_page() };
    let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;

    // Trigger background fetch (fire and forget)
    if let Some(outbox_url) = &actor.outbox_url {
        let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts {
            actor_ap_url: actor.url.clone(),
            outbox_url: outbox_url.clone(),
        }).await;
    }

    Ok(Json(serde_json::json!({
        "total": result.total,
        "page": result.page,
        "per_page": result.per_page,
        "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
    })))
}

Mount at GET /federation/actors/{handle}/posts in routes.rs.

Add pub mod federation_actors; to handlers/mod.rs.

Make to_thought_response in feed.rs pub so federation_actors.rs can import it.

api-types

Extend RemoteActorResponse:

pub struct RemoteActorResponse {
    pub handle: String,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub url: String,
    // new:
    pub bio: Option<String>,
    pub banner_url: Option<String>,
    pub also_known_as: Option<String>,
    pub outbox_url: Option<String>,
    pub attachment: Vec<ProfileField>,
}

pub struct ProfileField {
    pub name: String,
    pub value: String,
}

Update lookup_handler in users.rs to populate all new fields.

Worker

FederationEventService new deps

Add federation: Arc<dyn FederationActionPort> and ap_repo: Arc<dyn ActivityPubRepository> to FederationEventService. Handle the new event:

DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => {
    let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await {
        Ok(n) => n,
        Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); }
    };
    let actor_url = url::Url::parse(actor_ap_url)
        .map_err(|e| DomainError::ExternalService(e.to_string()))?;
    let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
    for note in notes {
        let ap_id = match url::Url::parse(&note.ap_id) {
            Ok(u) => u,
            Err(_) => continue,
        };
        // accept_note is idempotent — ignore duplicate errors
        let _ = self.ap_repo.accept_note(
            &ap_id, &author_id, &note.content, note.published,
            note.sensitive, note.content_warning, "public",
        ).await;
    }
    Ok(())
}

Wire new deps in worker/src/factory.rs.

Frontend

lib/api.ts

// Enriched RemoteActorSchema (same endpoint, more fields)
export const ProfileFieldSchema = z.object({
  name: z.string(),
  value: z.string(),
});

export const RemoteActorSchema = z.object({
  handle: z.string(),
  displayName: z.string().nullable(),
  avatarUrl: z.string().nullable(),
  url: z.string(),
  bio: z.string().nullable(),
  bannerUrl: z.string().nullable(),
  alsoKnownAs: z.string().nullable(),
  outboxUrl: z.string().nullable(),
  attachment: z.array(ProfileFieldSchema),
});

export const getRemoteActorPosts = (handle: string, page: number, token: string | null) =>
  apiFetch(
    `/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
    {},
    z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }),
    token
  );

app/users/[username]/page.tsx

Detect @user@domain regex. If handle: call lookupRemoteActor + getRemoteActorPosts in parallel; render <RemoteUserProfile>. Otherwise: existing local profile.

New components/remote-user-profile.tsx

Client component showing:

  • Banner (bannerUrl) — full-width image or placeholder
  • Avatar + display name + handle (@user@instance)
  • Bio (rendered as text)
  • Profile fields (attachment) — key-value table
  • "Also known as" link (if present)
  • External profile link button → url in new tab
  • Follow button (reuse followUser(handle, token))
  • Posts list using ThoughtList or similar, with empty state "Posts are loading, check back soon"
  • Pagination controls