docs: remote actor search & follow implementation plan
This commit is contained in:
917
docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md
Normal file
917
docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md
Normal file
@@ -0,0 +1,917 @@
|
|||||||
|
# Remote Actor Search & Follow 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:** Let local users search for and follow ActivityPub users on other instances (e.g. `@user@mastodon.social`) from the existing search page.
|
||||||
|
|
||||||
|
**Architecture:** New `FederationActionPort` domain trait (lookup + follow), implemented by `ActivityPubService` in `activitypub-base`. Injected into `AppState` via bootstrap. Two new REST endpoints at `/federation/lookup` and `/federation/follow`. Frontend detects `@user@instance` handle format in the search bar and renders a `RemoteUserCard` with a Follow button.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, sqlx, activitypub_federation crate), Next.js 15 (App Router, server components), TypeScript, Zod, shadcn/ui.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Modify | `crates/domain/src/models/remote_actor.rs` | Add `avatar_url` field |
|
||||||
|
| Modify | `crates/domain/src/errors.rs` | Add `ExternalService` variant |
|
||||||
|
| Modify | `crates/domain/src/ports.rs` | Add `FederationActionPort` trait |
|
||||||
|
| Modify | `crates/domain/src/testing.rs` | Impl `FederationActionPort` for `TestStore` |
|
||||||
|
| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `FederationActionPort` for `ActivityPubService` |
|
||||||
|
| Modify | `crates/adapters/activitypub-base/src/lib.rs` | Re-export trait impl visibility |
|
||||||
|
| Modify | `crates/presentation/src/state.rs` | Add `federation` field |
|
||||||
|
| Modify | `crates/presentation/src/errors.rs` | Map `ExternalService` → 502 |
|
||||||
|
| Modify | `crates/bootstrap/src/factory.rs` | Build `ActivityPubService`, wire `federation` |
|
||||||
|
| Modify | `crates/bootstrap/src/main.rs` | Use `ap_service.federation_config()` for middleware |
|
||||||
|
| Modify | `crates/api-types/src/responses.rs` | Add `RemoteActorResponse` |
|
||||||
|
| Create | `crates/presentation/src/handlers/federation.rs` | `lookup` + `follow_remote` handlers |
|
||||||
|
| Modify | `crates/presentation/src/handlers/mod.rs` | Expose `federation` module |
|
||||||
|
| Modify | `crates/presentation/src/routes.rs` | Mount `/federation/*` routes |
|
||||||
|
| Modify | `thoughts-frontend/lib/api.ts` | Add schema, `lookupRemoteActor`, `followRemoteUser` |
|
||||||
|
| Modify | `thoughts-frontend/app/search/page.tsx` | Detect handle, call lookup, pass result |
|
||||||
|
| Create | `thoughts-frontend/components/remote-user-card.tsx` | Shows remote actor + Follow button |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Domain model + port
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/domain/src/models/remote_actor.rs`
|
||||||
|
- Modify: `crates/domain/src/errors.rs`
|
||||||
|
- Modify: `crates/domain/src/ports.rs`
|
||||||
|
- Modify: `crates/domain/src/testing.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `avatar_url` to `RemoteActor`**
|
||||||
|
|
||||||
|
In `crates/domain/src/models/remote_actor.rs`, add one field:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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>, // ← add this
|
||||||
|
pub last_fetched_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `ExternalService` to `DomainError`**
|
||||||
|
|
||||||
|
In `crates/domain/src/errors.rs`, add the variant:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error("invalid input: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
#[error("external service error: {0}")]
|
||||||
|
ExternalService(String), // ← add this
|
||||||
|
#[error("internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `FederationActionPort` trait**
|
||||||
|
|
||||||
|
In `crates/domain/src/ports.rs`, after the `RemoteActorRepository` trait block, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederationActionPort: Send + Sync {
|
||||||
|
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
||||||
|
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure `RemoteActor` is already imported — it's in the existing `use crate::models::remote_actor::RemoteActor;` import block.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write failing tests for the trait in `testing.rs`**
|
||||||
|
|
||||||
|
At the bottom of `crates/domain/src/testing.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod federation_port_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::value_objects::UserId;
|
||||||
|
|
||||||
|
fn uid() -> UserId {
|
||||||
|
UserId::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_store_lookup_returns_not_found() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let err = store.lookup_actor("@alice@example.com").await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_store_follow_remote_is_noop_ok() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
store.follow_remote(&uid(), "@alice@example.com").await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests to see them fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p domain -- federation_port_tests 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile error — `lookup_actor` and `follow_remote` not implemented on `TestStore`, and `FederationActionPort` trait not found.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement `FederationActionPort` for `TestStore`**
|
||||||
|
|
||||||
|
In `crates/domain/src/testing.rs`, add after the existing `impl RemoteActorRepository for TestStore` block:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationActionPort for TestStore {
|
||||||
|
async fn lookup_actor(&self, _handle: &str) -> Result<RemoteActor, DomainError> {
|
||||||
|
Err(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn follow_remote(&self, _local_user_id: &UserId, _handle: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p domain -- federation_port_tests 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `test federation_port_tests::test_store_lookup_returns_not_found ... ok` and `test_store_follow_remote_is_noop_ok ... ok`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Confirm the whole domain crate still compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p domain 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/domain/src/models/remote_actor.rs \
|
||||||
|
crates/domain/src/errors.rs \
|
||||||
|
crates/domain/src/ports.rs \
|
||||||
|
crates/domain/src/testing.rs
|
||||||
|
git commit -m "feat(domain): FederationActionPort trait + avatar_url on RemoteActor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `activitypub-base` — implement `FederationActionPort`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a compile-time impl check in `tests/service.rs`**
|
||||||
|
|
||||||
|
In `crates/adapters/activitypub-base/src/tests/service.rs`, add at the top:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Verify ActivityPubService satisfies the FederationActionPort contract at compile time.
|
||||||
|
fn _assert_impl_federation_action_port()
|
||||||
|
where
|
||||||
|
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to see compile failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p activitypub-base 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: error — `ActivityPubService` does not implement `FederationActionPort`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `FederationActionPort` for `ActivityPubService`**
|
||||||
|
|
||||||
|
At the bottom of `crates/adapters/activitypub-base/src/service.rs`, before the closing of the file, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::FederationActionPort for ActivityPubService {
|
||||||
|
async fn lookup_actor(
|
||||||
|
&self,
|
||||||
|
handle: &str,
|
||||||
|
) -> Result<domain::models::remote_actor::RemoteActor, domain::errors::DomainError> {
|
||||||
|
use activitypub_federation::fetch::webfinger::webfinger_resolve_actor;
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let actor: crate::actors::DbActor = webfinger_resolve_actor(handle, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||||
|
Ok(domain::models::remote_actor::RemoteActor {
|
||||||
|
url: actor.ap_id.to_string(),
|
||||||
|
handle: actor.username.clone(),
|
||||||
|
display_name: actor.bio.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn follow_remote(
|
||||||
|
&self,
|
||||||
|
local_user_id: &domain::value_objects::UserId,
|
||||||
|
handle: &str,
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
self.follow(local_user_id.inner(), handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `UserId::inner()` returns the underlying `uuid::Uuid`. Verify the method name with `grep -n "fn inner\|fn as_uuid\|fn into_uuid" crates/domain/src/value_objects.rs` — adjust if the method is named differently.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Check `UserId` accessor method name**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "fn inner\|fn as_uuid\|fn into_uuid\|pub fn " /mnt/drive/dev/thoughts/crates/domain/src/value_objects.rs | grep -i "userid\|UserId" | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
If `inner()` doesn't exist, replace `local_user_id.inner()` with the correct method (e.g. `local_user_id.0`, `local_user_id.as_uuid()`, etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Compile to confirm the impl satisfies the trait**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p activitypub-base 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/adapters/activitypub-base/src/service.rs \
|
||||||
|
crates/adapters/activitypub-base/src/tests/service.rs
|
||||||
|
git commit -m "feat(activitypub-base): impl FederationActionPort for ActivityPubService"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Bootstrap — wire `ActivityPubService` into `AppState`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/presentation/src/state.rs`
|
||||||
|
- Modify: `crates/presentation/src/errors.rs`
|
||||||
|
- Modify: `crates/bootstrap/src/factory.rs`
|
||||||
|
- Modify: `crates/bootstrap/src/main.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `federation` to `AppState`**
|
||||||
|
|
||||||
|
In `crates/presentation/src/state.rs`, add the new field:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use domain::ports::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub users: Arc<dyn UserRepository>,
|
||||||
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||||
|
pub likes: Arc<dyn LikeRepository>,
|
||||||
|
pub boosts: Arc<dyn BoostRepository>,
|
||||||
|
pub follows: Arc<dyn FollowRepository>,
|
||||||
|
pub blocks: Arc<dyn BlockRepository>,
|
||||||
|
pub tags: Arc<dyn TagRepository>,
|
||||||
|
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||||
|
pub notifications: Arc<dyn NotificationRepository>,
|
||||||
|
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||||
|
pub feed: Arc<dyn FeedRepository>,
|
||||||
|
pub search: Arc<dyn SearchPort>,
|
||||||
|
pub auth: Arc<dyn AuthService>,
|
||||||
|
pub hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
pub federation: Arc<dyn FederationActionPort>, // ← add this
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Map `ExternalService` error in `presentation/src/errors.rs`**
|
||||||
|
|
||||||
|
Add the new match arm in `IntoResponse for ApiError`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Self::Domain(DomainError::ExternalService(_)) => (
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"external service error".into(),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
Place it before the `Self::Domain(DomainError::Internal(_))` arm.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Refactor `factory.rs` to build `ActivityPubService`**
|
||||||
|
|
||||||
|
In `crates/bootstrap/src/factory.rs`, change the imports and the federation setup block.
|
||||||
|
|
||||||
|
Add import at top:
|
||||||
|
```rust
|
||||||
|
use activitypub_base::service::ActivityPubService;
|
||||||
|
use domain::ports::FederationActionPort;
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `Infrastructure` struct:
|
||||||
|
```rust
|
||||||
|
pub struct Infrastructure {
|
||||||
|
pub state: AppState,
|
||||||
|
pub ap_service: Arc<ActivityPubService>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the current "3. ActivityPub federation" block (which builds `fed_data` + `fed_config`) with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 3. ActivityPub federation
|
||||||
|
let ap_service = Arc::new(
|
||||||
|
ActivityPubService::new(
|
||||||
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
|
Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())),
|
||||||
|
Arc::new(ThoughtsObjectHandler::new(
|
||||||
|
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
|
&cfg.base_url,
|
||||||
|
)),
|
||||||
|
cfg.base_url.clone(),
|
||||||
|
cfg.allow_registration,
|
||||||
|
"thoughts".to_string(),
|
||||||
|
cfg.debug,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to build ActivityPubService"),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the old `let fed_config = ...` line entirely.
|
||||||
|
|
||||||
|
In the `AppState { ... }` construction, add:
|
||||||
|
```rust
|
||||||
|
federation: ap_service.clone() as Arc<dyn FederationActionPort>,
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `Infrastructure { ... }` return to:
|
||||||
|
```rust
|
||||||
|
Infrastructure { state, ap_service }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `main.rs` to use `ap_service`**
|
||||||
|
|
||||||
|
In `crates/bootstrap/src/main.rs`, change the middleware line from:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.layer(infra.fed_config.middleware());
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.layer(infra.ap_service.federation_config().middleware());
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the AP router handlers — they use `actor_handler`, `inbox_handler`, etc. from `activitypub_base`. These don't change; only the middleware source changes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Confirm everything compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p bootstrap 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors. If `fed_config` is referenced elsewhere in `main.rs` or `factory.rs`, fix those references to use `ap_service.federation_config()`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/presentation/src/state.rs \
|
||||||
|
crates/presentation/src/errors.rs \
|
||||||
|
crates/bootstrap/src/factory.rs \
|
||||||
|
crates/bootstrap/src/main.rs
|
||||||
|
git commit -m "feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: REST endpoints — lookup + follow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/api-types/src/responses.rs`
|
||||||
|
- Create: `crates/presentation/src/handlers/federation.rs`
|
||||||
|
- Modify: `crates/presentation/src/handlers/mod.rs`
|
||||||
|
- Modify: `crates/presentation/src/routes.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `RemoteActorResponse` to `api-types`**
|
||||||
|
|
||||||
|
In `crates/api-types/src/responses.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing handler tests**
|
||||||
|
|
||||||
|
Create `crates/presentation/src/handlers/federation.rs` with the test module first:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
pub async fn lookup_handler(
|
||||||
|
State(_s): State<AppState>,
|
||||||
|
Query(_q): Query<LookupQuery>,
|
||||||
|
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_remote_handler(
|
||||||
|
State(_s): State<AppState>,
|
||||||
|
AuthUser(_uid): AuthUser,
|
||||||
|
Json(_body): Json<FollowRemoteRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LookupQuery {
|
||||||
|
pub handle: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, header},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use domain::testing::TestStore;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn make_state() -> AppState {
|
||||||
|
let store = Arc::new(TestStore::default());
|
||||||
|
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: store.clone(),
|
||||||
|
hasher: store.clone(),
|
||||||
|
events: store.clone(),
|
||||||
|
federation: store.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/federation/lookup", get(lookup_handler))
|
||||||
|
.route("/federation/follow", post(follow_remote_handler))
|
||||||
|
.with_state(make_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lookup_unknown_handle_returns_404() {
|
||||||
|
let resp = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/federation/lookup?handle=%40alice%40example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_remote_without_auth_returns_401() {
|
||||||
|
let resp = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/federation/follow")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"handle":"@alice@example.com"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `TestStore` must implement `AuthService`, `PasswordHasher`, and `FederationActionPort` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — if `TestStore` doesn't implement `AuthService` or `PasswordHasher`, use the existing pattern from other handler test setups in the codebase. You may need to construct `AppState` slightly differently (e.g. using a `NoOpAuth` stub). Check `crates/presentation/src/handlers/auth.rs` for any existing test patterns.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `FollowRemoteRequest` to `api-types`**
|
||||||
|
|
||||||
|
In `crates/api-types/src/requests.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FollowRemoteRequest {
|
||||||
|
pub handle: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to see them fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile errors (handler bodies are `todo!()`) or panics. The goal is to confirm the tests exist and the wiring is right.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the handlers**
|
||||||
|
|
||||||
|
Replace the `todo!()` bodies in `federation.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_remote_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
AuthUser(uid): AuthUser,
|
||||||
|
Json(body): Json<FollowRemoteRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
s.federation.follow_remote(&uid, &body.handle).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Expose the module**
|
||||||
|
|
||||||
|
In `crates/presentation/src/handlers/mod.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod federation;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Mount routes**
|
||||||
|
|
||||||
|
In `crates/presentation/src/routes.rs`, add these two routes inside `let api_routes = Router::new()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.route("/federation/lookup", get(federation::lookup_handler))
|
||||||
|
.route("/federation/follow", post(federation::follow_remote_handler))
|
||||||
|
```
|
||||||
|
|
||||||
|
Place them after the `/search` route for clarity.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests again to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
test handlers::federation::tests::lookup_unknown_handle_returns_404 ... ok
|
||||||
|
test handlers::federation::tests::follow_remote_without_auth_returns_401 ... ok
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Full compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/api-types/src/responses.rs \
|
||||||
|
crates/api-types/src/requests.rs \
|
||||||
|
crates/presentation/src/handlers/federation.rs \
|
||||||
|
crates/presentation/src/handlers/mod.rs \
|
||||||
|
crates/presentation/src/routes.rs
|
||||||
|
git commit -m "feat(presentation): /federation/lookup and /federation/follow endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — API client + search integration + RemoteUserCard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `thoughts-frontend/lib/api.ts`
|
||||||
|
- Modify: `thoughts-frontend/app/search/page.tsx`
|
||||||
|
- Create: `thoughts-frontend/components/remote-user-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add types and API functions to `lib/api.ts`**
|
||||||
|
|
||||||
|
After the `UserSchema` block (around line 15), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const RemoteActorSchema = z.object({
|
||||||
|
handle: z.string(),
|
||||||
|
displayName: z.string().nullable(),
|
||||||
|
avatarUrl: z.string().nullable(),
|
||||||
|
url: z.string(),
|
||||||
|
});
|
||||||
|
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
After the existing `followUser` and `unfollowUser` functions, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const lookupRemoteActor = (handle: string, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/federation/lookup?handle=${encodeURIComponent(handle)}`,
|
||||||
|
{},
|
||||||
|
RemoteActorSchema,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const followRemoteUser = (handle: string, token: string) =>
|
||||||
|
apiFetch(
|
||||||
|
`/federation/follow`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ handle }) },
|
||||||
|
z.null(),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `RemoteUserCard` component**
|
||||||
|
|
||||||
|
Create `thoughts-frontend/components/remote-user-card.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { followRemoteUser, RemoteActor } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
|
||||||
|
interface RemoteUserCardProps {
|
||||||
|
actor: RemoteActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
||||||
|
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 {
|
||||||
|
await followRemoteUser(actor.handle, token);
|
||||||
|
setFollowed(true);
|
||||||
|
toast.success(`Follow request sent to ${actor.handle}`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to send follow request.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar
|
||||||
|
username={actor.handle}
|
||||||
|
avatarUrl={actor.avatarUrl}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{actor.displayName ?? actor.handle}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{actor.handle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleFollow}
|
||||||
|
disabled={loading || followed}
|
||||||
|
variant={followed ? "secondary" : "default"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
{followed ? "Requested" : "Follow"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Check how `UserAvatar` is used in other components (e.g. `user-list-card.tsx`) to confirm the prop names match.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check `UserAvatar` props**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "UserAvatar\|avatarUrl\|username" /mnt/drive/dev/thoughts/thoughts-frontend/components/user-avatar.tsx | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the `UserAvatar` usage in `RemoteUserCard` to match the actual props.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `app/search/page.tsx` to detect handles and show remote result**
|
||||||
|
|
||||||
|
Replace the file with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getMe, search, lookupRemoteActor, User, RemoteActor } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
import { RemoteUserCard } from "@/components/remote-user-card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ThoughtList } from "@/components/thought-list";
|
||||||
|
|
||||||
|
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
|
||||||
|
|
||||||
|
interface SearchPageProps {
|
||||||
|
searchParams: Promise<{ q?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||||
|
const { q } = await searchParams;
|
||||||
|
const query = q || "";
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Find users and thoughts across the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHandle = HANDLE_RE.test(query);
|
||||||
|
|
||||||
|
const [results, remoteActor, me] = await Promise.all([
|
||||||
|
isHandle ? null : search(query, token).catch(() => null),
|
||||||
|
isHandle ? lookupRemoteActor(query, token).catch(() => null) : null,
|
||||||
|
token ? getMe(token).catch(() => null) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
|
if (results) {
|
||||||
|
results.users.forEach((user: User) => {
|
||||||
|
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Showing results for: "{query}"
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{isHandle ? (
|
||||||
|
remoteActor ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Remote user</h2>
|
||||||
|
<RemoteUserCard actor={remoteActor} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No user found at {query}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : results ? (
|
||||||
|
<Tabs defaultValue="thoughts" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="thoughts">
|
||||||
|
Thoughts ({results.thoughts.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">
|
||||||
|
Users ({results.users.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="thoughts">
|
||||||
|
<ThoughtList
|
||||||
|
thoughts={results.thoughts}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="users">
|
||||||
|
<UserListCard users={results.users} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No results found or an error occurred.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check the frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors. Fix any type mismatches before continuing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/drive/dev/thoughts/thoughts-frontend
|
||||||
|
git add lib/api.ts app/search/page.tsx components/remote-user-card.tsx
|
||||||
|
cd ..
|
||||||
|
git commit -m "feat(frontend): remote actor lookup and follow from search page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage check:**
|
||||||
|
- ✅ `FederationActionPort` trait with `lookup_actor` + `follow_remote` — Task 1
|
||||||
|
- ✅ `avatar_url` on `RemoteActor` — Task 1
|
||||||
|
- ✅ `ExternalService` error variant — Task 1
|
||||||
|
- ✅ `ActivityPubService` impl — Task 2
|
||||||
|
- ✅ Bootstrap refactor + `AppState.federation` — Task 3
|
||||||
|
- ✅ `RemoteActorResponse` + `FollowRemoteRequest` — Task 4
|
||||||
|
- ✅ `/federation/lookup` + `/federation/follow` endpoints — Task 4
|
||||||
|
- ✅ Error mapping (ExternalService → 502) — Task 3
|
||||||
|
- ✅ Frontend API client additions — Task 5
|
||||||
|
- ✅ Handle detection regex in search page — Task 5
|
||||||
|
- ✅ `RemoteUserCard` component — Task 5
|
||||||
|
|
||||||
|
**Placeholder check:** None found.
|
||||||
|
|
||||||
|
**Type consistency check:**
|
||||||
|
- `RemoteActor.avatar_url: Option<String>` used in Task 1, mapped from `DbActor.avatar_url: Option<Url>` in Task 2 via `.map(|u| u.to_string())` ✅
|
||||||
|
- `FollowRemoteRequest.handle` → `follow_remote(&uid, &body.handle)` ✅
|
||||||
|
- `RemoteActorResponse` fields match `RemoteActor` domain model fields ✅
|
||||||
|
- Frontend `RemoteActorSchema` camelCase fields match `#[serde(rename_all = "camelCase")]` on `RemoteActorResponse` ✅
|
||||||
|
- `UserId::inner()` — verified as an assumption in Task 2 Step 4 with an explicit check step ✅
|
||||||
Reference in New Issue
Block a user