diff --git a/docs/superpowers/plans/2026-05-14-remote-actor-profile.md b/docs/superpowers/plans/2026-05-14-remote-actor-profile.md new file mode 100644 index 0000000..c55d2d4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-remote-actor-profile.md @@ -0,0 +1,1288 @@ +# 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`: + +```rust +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct RemoteActor { + pub url: String, + pub handle: String, + pub display_name: Option, + pub inbox_url: String, + pub shared_inbox_url: Option, + pub public_key: String, + pub avatar_url: Option, + pub last_fetched_at: DateTime, + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub attachment: Vec<(String, String)>, +} +``` + +- [ ] **Step 2: Create `RemoteNote`** + +Create `crates/domain/src/models/remote_note.rs`: + +```rust +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct RemoteNote { + pub ap_id: String, + pub content: String, + pub published: DateTime, + pub sensitive: bool, + pub content_warning: Option, +} +``` + +- [ ] **Step 3: Register in `mod.rs`** + +In `crates/domain/src/models/mod.rs`, add: + +```rust +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): + +```rust +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: + +```rust +#[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** + +```bash +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`: + +```rust +async fn fetch_outbox_page( + &self, + outbox_url: &str, + page: u32, +) -> Result, 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: + +```rust +async fn fetch_outbox_page( + &self, + _outbox_url: &str, + _page: u32, +) -> Result, DomainError> { + Ok(vec![]) +} +``` + +- [ ] **Step 9: Fix `RemoteActor` construction sites** + +Adding new fields to `RemoteActor` will break all existing construction sites. Find them: + +```bash +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: + +```rust +bio: None, +banner_url: None, +also_known_as: None, +outbox_url: None, +attachment: vec![], +``` + +- [ ] **Step 10: Run tests to confirm pass** + +```bash +cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 11: Compile check** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5 +``` + +- [ ] **Step 12: Commit** + +```bash +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** + +```bash +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: + +```rust +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: + +```rust +async fn fetch_outbox_page( + &self, + outbox_url: &str, + page: u32, +) -> Result, 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** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 +``` + +- [ ] **Step 5: Run all tests** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +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): + +```rust +FetchRemoteActorPosts { + actor_ap_url: String, + outbox_url: String, +}, +``` + +- [ ] **Step 2: Add subject** + +In `impl EventPayload { pub fn subject(&self) -> &'static str { match self { ... } } }`, add: + +```rust +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: + +```rust +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` arm** + +In `impl TryFrom for DomainEvent { fn try_from(p: EventPayload) -> Result { Ok(match p { ... }) } }`, add: + +```rust +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 = vec![...]` in the `#[cfg(test)]` block). Add to the array: + +```rust +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** + +```bash +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** + +```bash +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: + +```rust +pub ap_repo: Arc, +``` + +`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: + +```rust +use postgres::activitypub::PgActivityPubRepository; +``` + +In the `AppState { ... }` construction block, add: + +```rust +ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), +``` + +- [ ] **Step 3: Compile check** + +```bash +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** + +```bash +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`: + +```rust +#[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, + pub avatar_url: Option, + pub url: String, + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub attachment: Vec, +} +``` + +- [ ] **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: + +```rust +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: + +```rust +pub async fn lookup_handler( + State(s): State, + Query(q): Query, +) -> Result, 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: + +```rust +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, + Path(_handle): Path, + Query(_q): Query, + OptionalAuthUser(_viewer): OptionalAuthUser, +) -> Result, 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`, pass `store.clone()`. + +- [ ] **Step 5: Add `pub mod federation_actors` to `mod.rs`** + +In `crates/presentation/src/handlers/mod.rs`, add: + +```rust +pub mod federation_actors; +``` + +- [ ] **Step 6: Run tests to see compile/fail state** + +```bash +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: + +```rust +pub async fn remote_actor_posts_handler( + State(s): State, + Path(handle): Path, + Query(q): Query, + OptionalAuthUser(viewer): OptionalAuthUser, +) -> Result, 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::>(), + }))) +} +``` + +Add the missing import at the top: + +```rust +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: + +```rust +.route( + "/federation/actors/{handle}/posts", + get(federation_actors::remote_actor_posts_handler), +) +``` + +- [ ] **Step 9: Run tests to confirm pass** + +```bash +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: + +```bash +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** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 12: Commit** + +```bash +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: + +```rust +pub struct FederationEventService { + pub thoughts: Arc, + pub users: Arc, + pub ap: Arc, + pub base_url: String, + pub federation_action: Arc, + pub ap_repo: Arc, +} +``` + +- [ ] **Step 2: Handle `FetchRemoteActorPosts` in `process()`** + +In the `match event { ... }` block in `process()`, add a new arm after `DomainEvent::BoostRemoved`: + +```rust +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: + +```rust +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`. Change to create it as a concrete `Arc` first, then cast: + +```rust +use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort}; +``` + +Replace the current `let ap_service: Arc = Arc::new(ActivityPubService::new(...).await.expect("..."))` with: + +```rust +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; +let ap_federation = ap_service.clone() as Arc; +let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc; +``` + +Update the `FederationEventService` construction: + +```rust +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: + +```rust +fn svc(store: &TestStore, spy: Arc) -> 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: + +```rust +#[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** + +```bash +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** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +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: + +```typescript +export const ProfileFieldSchema = z.object({ + name: z.string(), + value: z.string(), +}); +export type ProfileField = z.infer; + +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; +``` + +After `lookupRemoteActor`, add: + +```typescript +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`: + +```typescript +"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(); + initialPosts.forEach((t) => { + authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl }); + }); + + return ( +
+ {/* Banner */} +
+ +
+ {/* Left sidebar */} + + + {/* Posts */} +
+ {initialPosts.length > 0 ? ( + + ) : ( + +

+ Posts are being fetched — check back soon. +

+
+ )} +
+
+
+ ); +} +``` + +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: + +```typescript +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: + +```typescript +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 ; +} +``` + +Place this block immediately before the existing `const userProfilePromise = ...` line. The rest of the file continues unchanged. + +- [ ] **Step 4: Type-check** + +```bash +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** + +```bash +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` returned by `lookup_actor` (Task 2), used in handler (Task 5) and event payload (Task 3) ✅ +- `RemoteActorResponse.attachment: Vec` 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` 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` ✅