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

40 KiB

Remote Actor Profile 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: Display full remote actor profiles at /users/@user@instance — avatar, banner, bio, profile fields, and their public posts fetched in the background by the NATS worker.

Architecture: New DomainEvent::FetchRemoteActorPosts triggers the worker to fetch a remote outbox page and store notes via ActivityPubRepository::accept_note. A new REST endpoint returns cached posts + fires the event. The frontend detects the @user@domain URL format and renders a dedicated RemoteUserProfile component.

Tech Stack: Rust (axum, domain ports, activitypub_federation, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod, shadcn/ui.


File Map

Action Path Change
Modify crates/domain/src/models/remote_actor.rs Add 5 new fields
Create crates/domain/src/models/remote_note.rs New model
Modify crates/domain/src/models/mod.rs pub mod remote_note
Modify crates/domain/src/events.rs Add FetchRemoteActorPosts variant
Modify crates/domain/src/ports.rs Add fetch_outbox_page to FederationActionPort
Modify crates/domain/src/testing.rs Stub fetch_outbox_page on TestStore
Modify crates/adapters/activitypub-base/src/service.rs Impl fetch_outbox_page; populate new RemoteActor fields
Modify crates/adapters/event-payload/src/lib.rs Add FetchRemoteActorPosts to all 4 impls + test
Modify crates/presentation/src/state.rs Add ap_repo field
Modify crates/bootstrap/src/factory.rs Wire ap_repo into AppState
Modify crates/api-types/src/responses.rs Add ProfileField, extend RemoteActorResponse
Modify crates/presentation/src/handlers/feed.rs Make to_thought_response pub
Modify crates/presentation/src/handlers/users.rs Populate new RemoteActorResponse fields in lookup_handler
Create crates/presentation/src/handlers/federation_actors.rs remote_actor_posts_handler
Modify crates/presentation/src/handlers/mod.rs pub mod federation_actors
Modify crates/presentation/src/routes.rs Mount GET /federation/actors/{handle}/posts
Modify crates/application/src/services/federation_event.rs Handle FetchRemoteActorPosts; add new deps
Modify crates/worker/src/factory.rs Wire federation_action + ap_repo into FederationEventService
Modify thoughts-frontend/lib/api.ts Extend RemoteActorSchema; add getRemoteActorPosts
Create thoughts-frontend/components/remote-user-profile.tsx Full remote profile component
Modify thoughts-frontend/app/users/[username]/page.tsx Handle detection + remote profile branch

Task 1: Domain — extend RemoteActor, add RemoteNote, new event, new port method

Files:

  • Modify: crates/domain/src/models/remote_actor.rs

  • Create: crates/domain/src/models/remote_note.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: Extend RemoteActor with new fields

Replace the full content of crates/domain/src/models/remote_actor.rs:

use chrono::{DateTime, Utc};

#[derive(Debug, Clone)]
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>,
    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)>,
}
  • Step 2: Create RemoteNote

Create crates/domain/src/models/remote_note.rs:

use chrono::{DateTime, Utc};

#[derive(Debug, Clone)]
pub struct RemoteNote {
    pub ap_id: String,
    pub content: String,
    pub published: DateTime<Utc>,
    pub sensitive: bool,
    pub content_warning: Option<String>,
}
  • Step 3: Register in mod.rs

In crates/domain/src/models/mod.rs, add:

pub mod remote_note;
  • Step 4: Add FetchRemoteActorPosts to DomainEvent

Read crates/domain/src/events.rs. Add the new variant at the end of the enum (before the closing brace):

FetchRemoteActorPosts {
    actor_ap_url: String,
    outbox_url: String,
},
  • Step 5: Write failing test

In crates/domain/src/testing.rs, find the federation_port_tests module. Add:

#[tokio::test]
async fn test_store_fetch_outbox_returns_empty() {
    let store = TestStore::default();
    let notes = store.fetch_outbox_page("https://example.com/outbox", 1).await.unwrap();
    assert!(notes.is_empty());
}
  • Step 6: Run to see compile failure
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests::test_store_fetch_outbox 2>&1 | tail -10

Expected: compile error — fetch_outbox_page not in trait.

  • Step 7: Add fetch_outbox_page to FederationActionPort

Read crates/domain/src/ports.rs. In the FederationActionPort trait, add after following_collection_json:

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

Note: you need to import or reference RemoteNote. Since it's in the same crate, use the full path crate::models::remote_note::RemoteNote or add it to the use block at the top of the trait impl. Check what's currently imported and add use crate::models::remote_note::RemoteNote; to the imports if not present.

  • Step 8: Add stub to TestStore

In crates/domain/src/testing.rs, inside impl FederationActionPort for TestStore, add:

async fn fetch_outbox_page(
    &self,
    _outbox_url: &str,
    _page: u32,
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError> {
    Ok(vec![])
}
  • Step 9: Fix RemoteActor construction sites

Adding new fields to RemoteActor will break all existing construction sites. Find them:

cd /mnt/drive/dev/thoughts && grep -rn "RemoteActor {" --include="*.rs" | grep -v "target/"

For each construction site (likely in activitypub-base/src/actors.rs, activitypub-base/src/service.rs, adapters/postgres/src/remote_actor.rs), add the new fields with default None/vec![] values:

bio: None,
banner_url: None,
also_known_as: None,
outbox_url: None,
attachment: vec![],
  • Step 10: 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 11: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5
  • Step 12: Commit
cd /mnt/drive/dev/thoughts
git add crates/domain/src/models/remote_actor.rs \
        crates/domain/src/models/remote_note.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): RemoteActor fields, RemoteNote model, FetchRemoteActorPosts event, fetch_outbox_page port"

Task 2: activitypub-base — implement fetch_outbox_page + populate new RemoteActor fields

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

Expected: error — fetch_outbox_page not implemented on ActivityPubService.

  • Step 2: Update lookup_actor to populate new RemoteActor fields

Read crates/adapters/activitypub-base/src/service.rs. Find the lookup_actor impl. The current Ok(domain::models::remote_actor::RemoteActor { ... }) block sets handle: full_handle and avatar_url. Extend it with the new fields:

let domain_str = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain_str);

Ok(domain::models::remote_actor::RemoteActor {
    url: actor.ap_id.to_string(),
    handle: full_handle,
    display_name: Some(actor.username.clone()),
    inbox_url: actor.inbox_url.to_string(),
    shared_inbox_url: None,
    public_key: actor.public_key_pem.clone(),
    avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
    last_fetched_at: actor.last_refreshed_at,
    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(),
})
  • Step 3: Implement fetch_outbox_page

In the impl domain::ports::FederationActionPort for ActivityPubService block, after following_collection_json, add:

async fn fetch_outbox_page(
    &self,
    outbox_url: &str,
    page: u32,
) -> Result<Vec<domain::models::remote_note::RemoteNote>, domain::errors::DomainError> {
    use chrono::DateTime;

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

    let notes = items
        .iter()
        .filter_map(|item| {
            // Items are Create activities wrapping a Note, 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;
            }

            let published = DateTime::parse_from_rfc3339(
                note["published"].as_str()?,
            )
            .ok()?
            .with_timezone(&chrono::Utc);

            Some(domain::models::remote_note::RemoteNote {
                ap_id: note["id"].as_str()?.to_string(),
                content: note["content"].as_str().unwrap_or("").to_string(),
                published,
                sensitive: note["sensitive"].as_bool().unwrap_or(false),
                content_warning: note["summary"]
                    .as_str()
                    .map(|s| s.to_string()),
            })
        })
        .collect();

    Ok(notes)
}
  • Step 4: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
  • Step 5: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5

Expected: all pass.

  • Step 6: Commit
cd /mnt/drive/dev/thoughts
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(activitypub-base): impl fetch_outbox_page; populate all RemoteActor fields in lookup_actor"

Task 3: event-payload — add FetchRemoteActorPosts

Files:

  • Modify: crates/adapters/event-payload/src/lib.rs

  • Step 1: Add variant to EventPayload enum

Read the file. In the EventPayload enum, add at the end (before the closing brace):

FetchRemoteActorPosts {
    actor_ap_url: String,
    outbox_url: String,
},
  • Step 2: Add subject

In impl EventPayload { pub fn subject(&self) -> &'static str { match self { ... } } }, add:

Self::FetchRemoteActorPosts { .. } => "federation.fetch_actor_posts",
  • Step 3: Add From<&DomainEvent> arm

In impl From<&DomainEvent> for EventPayload { fn from(e: &DomainEvent) -> Self { match e { ... } } }, add:

DomainEvent::FetchRemoteActorPosts {
    actor_ap_url,
    outbox_url,
} => Self::FetchRemoteActorPosts {
    actor_ap_url: actor_ap_url.clone(),
    outbox_url: outbox_url.clone(),
},
  • Step 4: Add TryFrom<EventPayload> arm

In impl TryFrom<EventPayload> for DomainEvent { fn try_from(p: EventPayload) -> Result<Self, DomainError> { Ok(match p { ... }) } }, add:

EventPayload::FetchRemoteActorPosts {
    actor_ap_url,
    outbox_url,
} => DomainEvent::FetchRemoteActorPosts {
    actor_ap_url,
    outbox_url,
},
  • Step 5: Add to the uniqueness test sample array

Find the test that asserts each event has a unique subject (look for a let samples: Vec<EventPayload> = vec![...] in the #[cfg(test)] block). Add to the array:

EventPayload::FetchRemoteActorPosts {
    actor_ap_url: "https://mastodon.social/users/alice".into(),
    outbox_url: "https://mastodon.social/users/alice/outbox".into(),
},
  • Step 6: Compile and test
cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -10

Expected: all tests pass (uniqueness test passes with the 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): add FetchRemoteActorPosts event"

Task 4: AppState + bootstrap — add ap_repo

Files:

  • Modify: crates/presentation/src/state.rs

  • Modify: crates/bootstrap/src/factory.rs

  • Step 1: Add ap_repo to AppState

Read crates/presentation/src/state.rs. Add the new field:

pub ap_repo: Arc<dyn ActivityPubRepository>,

ActivityPubRepository is in domain::ports::* which is already imported via use domain::ports::*.

  • Step 2: Wire in factory.rs

Read crates/bootstrap/src/factory.rs. Add the import at the top if not present:

use postgres::activitypub::PgActivityPubRepository;

In the AppState { ... } construction block, add:

ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
  • Step 3: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p bootstrap 2>&1 | tail -10

Expected: no errors. (Presentation tests may fail with missing ap_repo in make_state() — they will be fixed in Task 5.)

  • Step 4: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/state.rs crates/bootstrap/src/factory.rs
git commit -m "feat(bootstrap): add ap_repo to AppState"

Task 5: REST endpoint — extend RemoteActorResponse, new handler, update lookup_handler

Files:

  • Modify: crates/api-types/src/responses.rs

  • Modify: crates/presentation/src/handlers/feed.rs

  • Modify: crates/presentation/src/handlers/users.rs

  • Create: crates/presentation/src/handlers/federation_actors.rs

  • Modify: crates/presentation/src/handlers/mod.rs

  • Modify: crates/presentation/src/routes.rs

  • Step 1: Add ProfileField + extend RemoteActorResponse in api-types

Read crates/api-types/src/responses.rs. Add a new struct and extend RemoteActorResponse:

#[derive(Serialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProfileField {
    pub name: String,
    pub value: String,
}

#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RemoteActorResponse {
    pub handle: String,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub url: String,
    pub bio: Option<String>,
    pub banner_url: Option<String>,
    pub also_known_as: Option<String>,
    pub outbox_url: Option<String>,
    pub attachment: Vec<ProfileField>,
}
  • Step 2: Make to_thought_response pub in feed.rs

Read crates/presentation/src/handlers/feed.rs. Find fn to_thought_response (currently private) and change it to:

pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
  • Step 3: Update lookup_handler in users.rs to populate new fields

Read crates/presentation/src/handlers/users.rs. Find lookup_handler. Update the Ok(Json(RemoteActorResponse { ... })) return to include all new fields:

pub async fn lookup_handler(
    State(s): State<AppState>,
    Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, ApiError> {
    let actor = s.federation.lookup_actor(&q.handle).await?;
    Ok(Json(RemoteActorResponse {
        handle: actor.handle,
        display_name: actor.display_name,
        avatar_url: actor.avatar_url,
        url: actor.url,
        bio: actor.bio,
        banner_url: actor.banner_url,
        also_known_as: actor.also_known_as,
        outbox_url: actor.outbox_url,
        attachment: actor
            .attachment
            .into_iter()
            .map(|(name, value)| api_types::responses::ProfileField { name, value })
            .collect(),
    }))
}
  • Step 4: Write failing tests for the new handler

Create crates/presentation/src/handlers/federation_actors.rs with tests first:

use crate::{
    errors::ApiError,
    extractors::OptionalAuthUser,
    handlers::feed::to_thought_response,
    state::AppState,
};
use api_types::requests::PaginationQuery;
use application::use_cases::feed::get_user_feed;
use axum::{
    extract::{Path, Query, State},
    Json,
};
use domain::{events::DomainEvent, models::feed::PageParams};

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> {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::Request,
        routing::get,
        Router,
    };
    use domain::testing::TestStore;
    use std::sync::Arc;
    use tower::ServiceExt;

    // Copy NoOpAuth and NoOpHasher structs from another handler test module
    // (e.g. crates/presentation/src/handlers/notifications.rs tests section).
    // They implement AuthService and PasswordHasher minimally for tests.

    fn make_state() -> crate::state::AppState {
        let store = Arc::new(TestStore::default());
        crate::state::AppState {
            users: store.clone(),
            thoughts: store.clone(),
            likes: store.clone(),
            boosts: store.clone(),
            follows: store.clone(),
            blocks: store.clone(),
            tags: store.clone(),
            api_keys: store.clone(),
            top_friends: store.clone(),
            notifications: store.clone(),
            remote_actors: store.clone(),
            feed: store.clone(),
            search: store.clone(),
            auth: Arc::new(NoOpAuth),
            hasher: Arc::new(NoOpHasher),
            events: store.clone(),
            federation: store.clone(),
            ap_repo: store.clone(),
        }
    }

    fn app() -> Router {
        Router::new()
            .route(
                "/federation/actors/{handle}/posts",
                get(remote_actor_posts_handler),
            )
            .with_state(make_state())
    }

    #[tokio::test]
    async fn unknown_actor_returns_404() {
        // TestStore.lookup_actor returns NotFound, so unknown handle → 404
        let resp = app()
            .oneshot(
                Request::builder()
                    .uri("/federation/actors/%40alice%40example.com/posts")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), 404);
    }
}

Note: TestStore must implement ActivityPubRepository for make_state() to compile. Check crates/domain/src/testing.rsTestStore already implements it (look for impl ActivityPubRepository for TestStore). If the ap_repo field expects Arc<dyn ActivityPubRepository>, pass store.clone().

  • Step 5: Add pub mod federation_actors to mod.rs

In crates/presentation/src/handlers/mod.rs, add:

pub mod federation_actors;
  • Step 6: Run tests to see compile/fail state
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -20

Expected: compile error or panic from todo!().

  • Step 7: Implement remote_actor_posts_handler

Replace the todo!() body with:

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 from DB
    let page = PageParams {
        page: q.page(),
        per_page: q.per_page(),
    };
    let result = get_user_feed(&*s.feed, &author_id, &page, viewer.as_ref()).await?;

    // Trigger background outbox fetch (fire and forget — ignore publish errors)
    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<_>>(),
    })))
}

Add the missing import at the top:

use application::use_cases::feed::get_user_feed;
use domain::{events::DomainEvent, models::feed::PageParams};
use url;
  • Step 8: Mount the route

Read crates/presentation/src/routes.rs. After the /search route, add:

.route(
    "/federation/actors/{handle}/posts",
    get(federation_actors::remote_actor_posts_handler),
)
  • Step 9: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -10

Expected: unknown_actor_returns_404 passes.

  • Step 10: Fix any broken tests caused by ap_repo in make_state()

Other test modules (notifications, social, users) also build AppState via make_state(). They will fail to compile because AppState now has ap_repo. Find them with:

cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "error" | head -20

For each test module that constructs AppState, add ap_repo: store.clone() to the struct literal.

  • Step 11: Full compile + test
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5

Expected: all pass.

  • Step 12: Commit
cd /mnt/drive/dev/thoughts
git add crates/api-types/src/responses.rs \
        crates/presentation/src/handlers/feed.rs \
        crates/presentation/src/handlers/users.rs \
        crates/presentation/src/handlers/federation_actors.rs \
        crates/presentation/src/handlers/mod.rs \
        crates/presentation/src/routes.rs
git commit -m "feat(presentation): remote actor posts endpoint + extended RemoteActorResponse"

Task 6: Worker — handle FetchRemoteActorPosts + wire deps

Files:

  • Modify: crates/application/src/services/federation_event.rs

  • Modify: crates/worker/src/factory.rs

  • Step 1: Add new deps to FederationEventService

Read crates/application/src/services/federation_event.rs. Add two new fields to the struct:

pub struct FederationEventService {
    pub thoughts: Arc<dyn ThoughtRepository>,
    pub users: Arc<dyn UserRepository>,
    pub ap: Arc<dyn OutboundFederationPort>,
    pub base_url: String,
    pub federation_action: Arc<dyn domain::ports::FederationActionPort>,
    pub ap_repo: Arc<dyn domain::ports::ActivityPubRepository>,
}
  • Step 2: Handle FetchRemoteActorPosts in process()

In the match event { ... } block in process(), add a new arm after DomainEvent::BoostRemoved:

DomainEvent::FetchRemoteActorPosts {
    actor_ap_url,
    outbox_url,
} => {
    let notes = match self
        .federation_action
        .fetch_outbox_page(outbox_url, 1)
        .await
    {
        Ok(n) => n,
        Err(e) => {
            tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox");
            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 — duplicate ap_ids are ignored
        let _ = self
            .ap_repo
            .accept_note(
                &ap_id,
                &author_id,
                &note.content,
                note.published,
                note.sensitive,
                note.content_warning,
                "public",
            )
            .await;
    }

    Ok(())
}

Add url to the imports at the top of the file if not already imported:

use url;
  • Step 3: Fix the FederationEventService construction in worker/factory.rs

Read crates/worker/src/factory.rs. Currently it creates ap_service as Arc<dyn OutboundFederationPort>. Change to create it as a concrete Arc<ActivityPubService> first, then cast:

use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort};

Replace the current let ap_service: Arc<dyn domain::ports::OutboundFederationPort> = Arc::new(ActivityPubService::new(...).await.expect("...")) with:

let ap_service = Arc::new(
    ActivityPubService::new(
        Arc::new(PostgresFederationRepository::new(pool.clone())),
        Arc::new(PostgresApUserRepository::new(
            pool.clone(),
            base_url.to_string(),
        )),
        Arc::new(ThoughtsObjectHandler::new(
            Arc::new(PgActivityPubRepository::new(pool.clone())),
            base_url,
        )),
        base_url.to_string(),
        false,
        "thoughts".to_string(),
        false,
        None,
    )
    .await
    .expect("ActivityPubService build failed"),
);
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
let ap_federation = ap_service.clone() as Arc<dyn FederationActionPort>;
let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;

Update the FederationEventService construction:

let federation_svc = Arc::new(FederationEventService {
    thoughts,
    users,
    ap: ap_outbound,
    base_url: base_url.to_string(),
    federation_action: ap_federation,
    ap_repo: ap_repo_worker,
});
  • Step 4: Fix existing tests in federation_event.rs

The svc() helper in tests constructs FederationEventService and will now fail because of missing new fields. Find the helper and add:

fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
    FederationEventService {
        thoughts: Arc::new(store.clone()),
        users: Arc::new(store.clone()),
        ap: spy,
        base_url: "https://example.com".to_string(),
        federation_action: Arc::new(store.clone()),  // TestStore implements FederationActionPort
        ap_repo: Arc::new(store.clone()),             // TestStore implements ActivityPubRepository
    }
}
  • Step 5: Write a test for FetchRemoteActorPosts

In the #[cfg(test)] block of federation_event.rs, add after the existing tests:

#[tokio::test]
async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() {
    // TestStore.fetch_outbox_page returns Ok(vec![]) — no notes to store
    let store = TestStore::default();
    let spy = Arc::new(SpyPort::default());
    svc(&store, spy.clone())
        .process(&DomainEvent::FetchRemoteActorPosts {
            actor_ap_url: "https://mastodon.social/users/alice".into(),
            outbox_url: "https://mastodon.social/users/alice/outbox".into(),
        })
        .await
        .unwrap();
    // No assertions needed — just confirm it doesn't panic or error
}
  • Step 6: Run tests
cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -15

Expected: all existing federation_event tests pass + new test passes.

  • Step 7: Full compile + test suite
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/application/src/services/federation_event.rs \
        crates/worker/src/factory.rs
git commit -m "feat(worker): handle FetchRemoteActorPosts — fetch and store remote outbox notes"

Task 7: Frontend — API + RemoteUserProfile component + page routing

Files:

  • Modify: thoughts-frontend/lib/api.ts

  • Create: thoughts-frontend/components/remote-user-profile.tsx

  • Modify: thoughts-frontend/app/users/[username]/page.tsx

  • Step 1: Extend RemoteActorSchema and add getRemoteActorPosts in api.ts

Read thoughts-frontend/lib/api.ts. Replace RemoteActorSchema with the enriched version:

export const ProfileFieldSchema = z.object({
  name: z.string(),
  value: z.string(),
});
export type ProfileField = z.infer<typeof ProfileFieldSchema>;

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 type RemoteActor = z.infer<typeof RemoteActorSchema>;

After lookupRemoteActor, add:

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
  );
  • Step 2: Create RemoteUserProfile component

Create thoughts-frontend/components/remote-user-profile.tsx:

"use client";

import { useState } from "react";
import Link from "next/link";
import { UserAvatar } from "@/components/user-avatar";
import { ThoughtList } from "@/components/thought-list";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ExternalLink, UserPlus, UserMinus } from "lucide-react";
import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";

interface RemoteUserProfileProps {
  actor: RemoteActor;
  initialPosts: Thought[];
  me: Me | null;
}

export function RemoteUserProfile({
  actor,
  initialPosts,
  me,
}: RemoteUserProfileProps) {
  const [followed, setFollowed] = useState(false);
  const [loading, setLoading] = useState(false);
  const { token } = useAuth();

  const handleFollow = async () => {
    if (!token) {
      toast.error("You must be logged in to follow users.");
      return;
    }
    setLoading(true);
    try {
      if (followed) {
        await unfollowUser(actor.handle, token);
        setFollowed(false);
      } else {
        await followUser(actor.handle, token);
        setFollowed(true);
        toast.success(`Follow request sent to ${actor.handle}`);
      }
    } catch {
      toast.error(followed ? "Failed to unfollow." : "Failed to send follow request.");
    } finally {
      setLoading(false);
    }
  };

  const isOwnProfile = me?.username === actor.handle;

  // Build authorDetails for ThoughtList
  const authorDetails = new Map<string, { avatarUrl?: string | null }>();
  initialPosts.forEach((t) => {
    authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl });
  });

  return (
    <div>
      {/* Banner */}
      <div
        className="h-48 bg-muted bg-cover bg-center"
        style={{
          backgroundImage: actor.bannerUrl ? `url(${actor.bannerUrl})` : "none",
        }}
      />

      <main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
        {/* Left sidebar */}
        <aside className="col-span-1 space-y-6">
          <div className="sticky top-20 space-y-6">
            <Card className="p-6 bg-card/80 backdrop-blur-lg">
              <div className="flex justify-between items-start">
                <div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
                  <UserAvatar
                    src={actor.avatarUrl}
                    alt={actor.displayName}
                    className="w-full h-full"
                  />
                </div>
                {!isOwnProfile && token && (
                  <Button
                    onClick={handleFollow}
                    disabled={loading}
                    variant={followed ? "secondary" : "default"}
                    size="sm"
                  >
                    {followed ? (
                      <><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
                    ) : (
                      <><UserPlus className="mr-2 h-4 w-4" /> Follow</>
                    )}
                  </Button>
                )}
              </div>

              <div className="mt-4">
                <h1 className="text-2xl font-bold">
                  {actor.displayName ?? actor.handle}
                </h1>
                <p className="text-sm text-muted-foreground">{actor.handle}</p>
              </div>

              {actor.bio && (
                <p className="mt-4 text-sm whitespace-pre-wrap">{actor.bio}</p>
              )}

              <Button asChild variant="outline" size="sm" className="mt-4 w-full">
                <Link href={actor.url} target="_blank" rel="noopener noreferrer">
                  <ExternalLink className="mr-2 h-4 w-4" />
                  View on {new URL(actor.url).hostname}
                </Link>
              </Button>

              {actor.alsoKnownAs && (
                <p className="mt-2 text-xs text-muted-foreground">
                  Also known as:{" "}
                  <Link
                    href={actor.alsoKnownAs}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="underline"
                  >
                    {actor.alsoKnownAs}
                  </Link>
                </p>
              )}

              {actor.attachment.length > 0 && (
                <table className="mt-4 w-full text-sm border-collapse">
                  <tbody>
                    {actor.attachment.map((field) => (
                      <tr key={field.name} className="border-t">
                        <td className="py-1 pr-2 font-medium text-muted-foreground">
                          {field.name}
                        </td>
                        <td
                          className="py-1"
                          dangerouslySetInnerHTML={{ __html: field.value }}
                        />
                      </tr>
                    ))}
                  </tbody>
                </table>
              )}
            </Card>
          </div>
        </aside>

        {/* Posts */}
        <div className="col-span-1 lg:col-span-3 space-y-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>
          )}
        </div>
      </main>
    </div>
  );
}

Note: dangerouslySetInnerHTML on field.value is needed because Mastodon returns HTML in profile field values (e.g. links). This is safe because the data comes from a trusted AP fetch, not user input.

  • Step 3: Update app/users/[username]/page.tsx to handle remote actors

Read the full file. Add a handle-detection branch at the top of ProfilePage, before the existing promise setup:

import {
  getFollowersList,
  getFollowingList,
  getMe,
  getTopFriends,
  getUserProfile,
  getUserThoughts,
  lookupRemoteActor,
  getRemoteActorPosts,
  Me,
} from "@/lib/api";
import { RemoteUserProfile } from "@/components/remote-user-profile";
// ... existing imports unchanged

After const { username } = await params; and const token = ..., add the branch:

const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;

if (HANDLE_RE.test(username)) {
  const [actorResult, postsResult, meResult] = await Promise.allSettled([
    lookupRemoteActor(username, token),
    getRemoteActorPosts(username, 1, token),
    token ? getMe(token) : Promise.resolve(null),
  ]);

  if (actorResult.status === "rejected") {
    notFound();
  }

  const actor = actorResult.value;
  const posts =
    postsResult.status === "fulfilled" ? postsResult.value.items : [];
  const me =
    meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;

  return <RemoteUserProfile actor={actor} initialPosts={posts} me={me} />;
}

Place this block immediately before the existing const userProfilePromise = ... line. The rest of the file continues unchanged.

  • Step 4: Type-check
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20

Expected: no errors. If ThoughtList props don't match, check its interface and adjust.

  • Step 5: Commit
cd /mnt/drive/dev/thoughts
git add thoughts-frontend/lib/api.ts \
        thoughts-frontend/components/remote-user-profile.tsx \
        thoughts-frontend/app/users/[username]/page.tsx
git commit -m "feat(frontend): remote actor profile page with bio, fields, and posts"

Self-Review

Spec coverage:

  • RemoteActor extended with bio, banner_url, also_known_as, outbox_url, attachment — Task 1 + 2
  • RemoteNote domain model — Task 1
  • FetchRemoteActorPosts domain event — Task 1
  • fetch_outbox_page port method — Task 1 + 2
  • fetch_outbox_page impl (HTTPS, Create/Note both handled, public-only filter) — Task 2
  • lookup_actor populates new fields — Task 2
  • EventPayload::FetchRemoteActorPosts (enum, subject, From, TryFrom, test) — Task 3
  • AppState.ap_repo wired — Task 4
  • ProfileField + extended RemoteActorResponse — Task 5
  • to_thought_response made pub — Task 5
  • lookup_handler updated to return new fields — Task 5
  • GET /federation/actors/{handle}/posts endpoint — Task 5
  • Worker handles FetchRemoteActorPosts — Task 6
  • Worker factory wires new deps — Task 6
  • RemoteActorSchema extended + getRemoteActorPosts — Task 7
  • RemoteUserProfile component (banner, avatar, bio, fields, alsoKnownAs, external link, follow, posts) — Task 7
  • Handle detection in profile page — Task 7

Placeholder scan: None found.

Type consistency:

  • RemoteNote { ap_id, content, published, sensitive, content_warning } defined Task 1, used in Task 2 impl and Task 6 worker
  • actor.outbox_url: Option<String> returned by lookup_actor (Task 2), used in handler (Task 5) and event payload (Task 3)
  • RemoteActorResponse.attachment: Vec<ProfileField> defined Task 5, mapped from actor.attachment: Vec<(String, String)> in Task 2
  • FederationEventService { federation_action, ap_repo } — new fields added Task 6 step 1, wired in factory Task 6 step 3, test helper updated Task 6 step 4
  • ap_repo: Arc<dyn ActivityPubRepository> in AppState added Task 4, used in Task 5 handler, used in test make_state() Task 5 step 4
  • getRemoteActorPosts returns { items: ThoughtSchema[] }ThoughtSchema already imported in api.ts