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
RemoteActorwith 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
FetchRemoteActorPoststoDomainEvent
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_pagetoFederationActionPort
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
RemoteActorconstruction 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_actorto populate newRemoteActorfields
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
EventPayloadenum
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_repotoAppState
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+ extendRemoteActorResponsein 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_responsepub infeed.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_handlerinusers.rsto 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.rs — TestStore 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_actorstomod.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_repoinmake_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
FetchRemoteActorPostsinprocess()
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(¬e.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,
¬e.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
FederationEventServiceconstruction inworker/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
RemoteActorSchemaand addgetRemoteActorPostsinapi.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
RemoteUserProfilecomponent
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.tsxto 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:
- ✅
RemoteActorextended with bio, banner_url, also_known_as, outbox_url, attachment — Task 1 + 2 - ✅
RemoteNotedomain model — Task 1 - ✅
FetchRemoteActorPostsdomain event — Task 1 - ✅
fetch_outbox_pageport method — Task 1 + 2 - ✅
fetch_outbox_pageimpl (HTTPS, Create/Note both handled, public-only filter) — Task 2 - ✅
lookup_actorpopulates new fields — Task 2 - ✅
EventPayload::FetchRemoteActorPosts(enum, subject, From, TryFrom, test) — Task 3 - ✅
AppState.ap_repowired — Task 4 - ✅
ProfileField+ extendedRemoteActorResponse— Task 5 - ✅
to_thought_responsemade pub — Task 5 - ✅
lookup_handlerupdated to return new fields — Task 5 - ✅
GET /federation/actors/{handle}/postsendpoint — Task 5 - ✅ Worker handles
FetchRemoteActorPosts— Task 6 - ✅ Worker factory wires new deps — Task 6
- ✅
RemoteActorSchemaextended +getRemoteActorPosts— Task 7 - ✅
RemoteUserProfilecomponent (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 bylookup_actor(Task 2), used in handler (Task 5) and event payload (Task 3) ✅RemoteActorResponse.attachment: Vec<ProfileField>defined Task 5, mapped fromactor.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>inAppStateadded Task 4, used in Task 5 handler, used in testmake_state()Task 5 step 4 ✅getRemoteActorPostsreturns{ items: ThoughtSchema[] }—ThoughtSchemaalready imported inapi.ts✅