feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
300
docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md
Normal file
300
docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md
Normal file
@@ -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<String>,
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub public_key: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub last_fetched_at: DateTime<Utc>,
|
||||
// new:
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<(String, String)>, // (name, value)
|
||||
}
|
||||
```
|
||||
|
||||
### New `domain/src/models/remote_note.rs`
|
||||
|
||||
```rust
|
||||
pub struct RemoteNote {
|
||||
pub ap_id: String,
|
||||
pub content: String,
|
||||
pub published: chrono::DateTime<chrono::Utc>,
|
||||
pub sensitive: bool,
|
||||
pub content_warning: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||
|
||||
```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<Vec<RemoteNote>, 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<Vec<RemoteNote>, DomainError> {
|
||||
let url = format!("{}?page={}", outbox_url, page);
|
||||
let resp: serde_json::Value = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send().await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json().await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
|
||||
Ok(items.iter().filter_map(|item| {
|
||||
// Items are Create activities or Notes directly
|
||||
let note = if item["type"].as_str() == Some("Create") {
|
||||
&item["object"]
|
||||
} else if item["type"].as_str() == Some("Note") {
|
||||
item
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// Only public notes
|
||||
let to = note["to"].as_array()?;
|
||||
let is_public = to.iter().any(|t| {
|
||||
t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")
|
||||
});
|
||||
if !is_public { return None; }
|
||||
Some(RemoteNote {
|
||||
ap_id: note["id"].as_str()?.to_string(),
|
||||
content: note["content"].as_str().unwrap_or("").to_string(),
|
||||
published: chrono::DateTime::parse_from_rfc3339(
|
||||
note["published"].as_str()?
|
||||
).ok()?.with_timezone(&chrono::Utc),
|
||||
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||
})
|
||||
}).collect())
|
||||
}
|
||||
```
|
||||
|
||||
## AppState + Bootstrap
|
||||
|
||||
Add `ap_repo: Arc<dyn ActivityPubRepository>` to `presentation/src/state.rs`.
|
||||
|
||||
Wire in `bootstrap/src/factory.rs`:
|
||||
```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<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&handle).await?;
|
||||
let ap_url = url::Url::parse(&actor.url)
|
||||
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Get or create interned local UserId for this remote actor
|
||||
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
|
||||
Some(id) => id,
|
||||
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
|
||||
};
|
||||
|
||||
// Return cached posts
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;
|
||||
|
||||
// Trigger background fetch (fire and forget)
|
||||
if let Some(outbox_url) = &actor.outbox_url {
|
||||
let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url: actor.url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
}).await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`.
|
||||
|
||||
Add `pub mod federation_actors;` to `handlers/mod.rs`.
|
||||
|
||||
Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it.
|
||||
|
||||
## api-types
|
||||
|
||||
Extend `RemoteActorResponse`:
|
||||
```rust
|
||||
pub struct RemoteActorResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
// new:
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<ProfileField>,
|
||||
}
|
||||
|
||||
pub struct ProfileField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
```
|
||||
|
||||
Update `lookup_handler` in `users.rs` to populate all new fields.
|
||||
|
||||
## Worker
|
||||
|
||||
### `FederationEventService` new deps
|
||||
|
||||
Add `federation: Arc<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` to `FederationEventService`. Handle the new event:
|
||||
|
||||
```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 `<RemoteUserProfile>`. Otherwise: existing local profile.
|
||||
|
||||
### New `components/remote-user-profile.tsx`
|
||||
|
||||
Client component showing:
|
||||
- Banner (`bannerUrl`) — full-width image or placeholder
|
||||
- Avatar + display name + handle (`@user@instance`)
|
||||
- Bio (rendered as text)
|
||||
- Profile fields (`attachment`) — key-value table
|
||||
- "Also known as" link (if present)
|
||||
- External profile link button → `url` in new tab
|
||||
- Follow button (reuse `followUser(handle, token)`)
|
||||
- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon"
|
||||
- Pagination controls
|
||||
Reference in New Issue
Block a user