diff --git a/docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md b/docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md new file mode 100644 index 0000000..01f322e --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md @@ -0,0 +1,300 @@ +# Remote Actor Profile Design + +Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker. + +## Data Flow + +1. User navigates to `/users/@gabrielkaszewski@mastodon.social` +2. Frontend detects `@user@domain` format, calls in parallel: + - `GET /users/lookup?handle=@user@instance` → enriched profile metadata + - `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit) +3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget +4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note` +5. On next visit/refresh posts are populated + +## Domain Changes + +### Extend `domain/src/models/remote_actor.rs` + +Add fields: +```rust +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, + // new: + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub attachment: Vec<(String, String)>, // (name, value) +} +``` + +### New `domain/src/models/remote_note.rs` + +```rust +pub struct RemoteNote { + pub ap_id: String, + pub content: String, + pub published: chrono::DateTime, + pub sensitive: bool, + pub content_warning: Option, +} +``` + +### New `DomainEvent` variant (`domain/src/events.rs`) + +```rust +FetchRemoteActorPosts { + actor_ap_url: String, + outbox_url: String, +} +``` + +### New `FederationActionPort` method (`domain/src/ports.rs`) + +```rust +async fn fetch_outbox_page( + &self, + outbox_url: &str, + page: u32, +) -> Result, DomainError>; +``` + +`TestStore` stub returns `Ok(vec![])`. + +## activitypub-base Implementation + +### `lookup_actor` — populate new `RemoteActor` fields + +Map from `DbActor`: +```rust +bio: actor.bio.clone(), +banner_url: actor.banner_url.as_ref().map(|u| u.to_string()), +also_known_as: actor.also_known_as.clone(), +outbox_url: Some(actor.outbox_url.to_string()), +attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(), +``` + +### `fetch_outbox_page` impl on `ActivityPubService` + +```rust +async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result, DomainError> { + let url = format!("{}?page={}", outbox_url, page); + let resp: serde_json::Value = reqwest::Client::new() + .get(&url) + .header("Accept", "application/activity+json, application/ld+json") + .send().await + .map_err(|e| DomainError::ExternalService(e.to_string()))? + .json().await + .map_err(|e| DomainError::ExternalService(e.to_string()))?; + + let items = resp["orderedItems"].as_array().cloned().unwrap_or_default(); + Ok(items.iter().filter_map(|item| { + // Items are Create activities or Notes directly + let note = if item["type"].as_str() == Some("Create") { + &item["object"] + } else if item["type"].as_str() == Some("Note") { + item + } else { + return None; + }; + // Only public notes + let to = note["to"].as_array()?; + let is_public = to.iter().any(|t| { + t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public") + }); + if !is_public { return None; } + Some(RemoteNote { + ap_id: note["id"].as_str()?.to_string(), + content: note["content"].as_str().unwrap_or("").to_string(), + published: chrono::DateTime::parse_from_rfc3339( + note["published"].as_str()? + ).ok()?.with_timezone(&chrono::Utc), + sensitive: note["sensitive"].as_bool().unwrap_or(false), + content_warning: note["summary"].as_str().map(|s| s.to_string()), + }) + }).collect()) +} +``` + +## AppState + Bootstrap + +Add `ap_repo: Arc` to `presentation/src/state.rs`. + +Wire in `bootstrap/src/factory.rs`: +```rust +ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), +``` + +## event-payload + +Add to `EventPayload` enum: +```rust +FetchRemoteActorPosts { + actor_ap_url: String, + outbox_url: String, +} +``` + +Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test. + +## REST Endpoint + +**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`): + +```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 + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?; + + // Trigger background fetch (fire and forget) + if let Some(outbox_url) = &actor.outbox_url { + let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts { + actor_ap_url: actor.url.clone(), + outbox_url: outbox_url.clone(), + }).await; + } + + Ok(Json(serde_json::json!({ + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>(), + }))) +} +``` + +Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`. + +Add `pub mod federation_actors;` to `handlers/mod.rs`. + +Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it. + +## api-types + +Extend `RemoteActorResponse`: +```rust +pub struct RemoteActorResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, + // new: + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub attachment: Vec, +} + +pub struct ProfileField { + pub name: String, + pub value: String, +} +``` + +Update `lookup_handler` in `users.rs` to populate all new fields. + +## Worker + +### `FederationEventService` new deps + +Add `federation: Arc` and `ap_repo: Arc` to `FederationEventService`. Handle the new event: + +```rust +DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => { + let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await { + Ok(n) => n, + Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); } + }; + let actor_url = url::Url::parse(actor_ap_url) + .map_err(|e| DomainError::ExternalService(e.to_string()))?; + let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?; + for note in notes { + let ap_id = match url::Url::parse(¬e.ap_id) { + Ok(u) => u, + Err(_) => continue, + }; + // accept_note is idempotent — ignore duplicate errors + let _ = self.ap_repo.accept_note( + &ap_id, &author_id, ¬e.content, note.published, + note.sensitive, note.content_warning, "public", + ).await; + } + Ok(()) +} +``` + +Wire new deps in `worker/src/factory.rs`. + +## Frontend + +### `lib/api.ts` + +```typescript +// Enriched RemoteActorSchema (same endpoint, more fields) +export const ProfileFieldSchema = z.object({ + name: z.string(), + value: z.string(), +}); + +export const RemoteActorSchema = z.object({ + handle: z.string(), + displayName: z.string().nullable(), + avatarUrl: z.string().nullable(), + url: z.string(), + bio: z.string().nullable(), + bannerUrl: z.string().nullable(), + alsoKnownAs: z.string().nullable(), + outboxUrl: z.string().nullable(), + attachment: z.array(ProfileFieldSchema), +}); + +export const getRemoteActorPosts = (handle: string, page: number, token: string | null) => + apiFetch( + `/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`, + {}, + z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }), + token + ); +``` + +### `app/users/[username]/page.tsx` + +Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render ``. Otherwise: existing local profile. + +### New `components/remote-user-profile.tsx` + +Client component showing: +- Banner (`bannerUrl`) — full-width image or placeholder +- Avatar + display name + handle (`@user@instance`) +- Bio (rendered as text) +- Profile fields (`attachment`) — key-value table +- "Also known as" link (if present) +- External profile link button → `url` in new tab +- Follow button (reuse `followUser(handle, token)`) +- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon" +- Pagination controls