Compare commits

...

11 Commits

Author SHA1 Message Date
dbd891d60d fix(activitypub-base): lookup_actor fetches WebFinger via HTTPS directly
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m40s
test / unit (pull_request) Successful in 16m33s
test / integration (pull_request) Failing after 17m33s
2026-05-14 20:25:14 +02:00
baf8b57b6d fix(activitypub-base): strip leading @ from handle before WebFinger lookup 2026-05-14 20:16:00 +02:00
a7a331858d feat(frontend): remote actor lookup and follow from search page 2026-05-14 20:09:49 +02:00
31487882e0 feat(presentation): /federation/lookup and /federation/follow endpoints 2026-05-14 20:06:55 +02:00
a08bb3d698 feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState 2026-05-14 20:03:49 +02:00
1d50b54227 fix(activitypub-base): use username as display_name in lookup_actor 2026-05-14 20:02:01 +02:00
fce819be7f feat(activitypub-base): impl FederationActionPort for ActivityPubService 2026-05-14 19:59:19 +02:00
0e45707d7e fix(postgres): persist and read avatar_url in remote_actor adapter 2026-05-14 19:57:13 +02:00
82f8772104 feat(domain): FederationActionPort trait + avatar_url on RemoteActor 2026-05-14 19:55:10 +02:00
8eb59bfac6 docs: remote actor search & follow implementation plan 2026-05-14 19:52:29 +02:00
62970d519a docs: remote actor search & follow spec 2026-05-14 19:48:34 +02:00
21 changed files with 1418 additions and 31 deletions

View File

@@ -1330,6 +1330,81 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
}
}
#[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::object_id::ObjectId;
let normalized = handle.trim_start_matches('@');
let at = normalized.rfind('@').ok_or_else(|| {
domain::errors::DomainError::InvalidInput("handle must be user@domain".into())
})?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
// Fetch WebFinger over HTTPS directly — the library's webfinger_resolve_actor
// tries HTTP first in debug mode, which fails on servers without HTTP→HTTPS redirect.
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/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 self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json")
})
})
.and_then(|l| l["href"].as_str())
.ok_or(domain::errors::DomainError::NotFound)?;
let self_url = url::Url::parse(self_href)
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let data = self.federation_config.to_request_data();
let actor: crate::actors::DbActor = ObjectId::from(self_url)
.dereference(&data)
.await
.map_err(|e: crate::error::Error| {
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: 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,
})
}
async fn follow_remote(
&self,
local_user_id: &domain::value_objects::UserId,
handle: &str,
) -> Result<(), domain::errors::DomainError> {
self.follow(local_user_id.as_uuid(), handle)
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}
}
#[cfg(test)]
#[path = "tests/service.rs"]
mod tests;

View File

@@ -1,3 +1,9 @@
fn _assert_impl_federation_action_port()
where
crate::service::ActivityPubService: domain::ports::FederationActionPort,
{
}
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};

View File

@@ -18,14 +18,14 @@ impl PgRemoteActorRepository {
impl RemoteActorRepository for PgRemoteActorRepository {
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at)
VALUES($1,$2,$3,$4,$5,$6,$7)
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
public_key=EXCLUDED.public_key,last_fetched_at=EXCLUDED.last_fetched_at"
public_key=EXCLUDED.public_key,avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
)
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.inbox_url)
.bind(&a.shared_inbox_url).bind(&a.public_key).bind(a.last_fetched_at)
.bind(&a.shared_inbox_url).bind(&a.public_key).bind(&a.avatar_url).bind(a.last_fetched_at)
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
}
@@ -38,12 +38,13 @@ impl RemoteActorRepository for PgRemoteActorRepository {
inbox_url: String,
shared_inbox_url: Option<String>,
public_key: String,
avatar_url: Option<String>,
last_fetched_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>(
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at FROM remote_actors WHERE url=$1"
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
).bind(url).fetch_optional(&self.pool).await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, last_fetched_at: r.last_fetched_at }))
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at }))
}
}

View File

@@ -80,3 +80,9 @@ pub struct SearchQuery {
pub page: Option<u64>,
pub per_page: Option<u64>,
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FollowRemoteRequest {
pub handle: String,
}

View File

@@ -87,3 +87,12 @@ pub struct CreatedApiKeyResponse {
/// Raw API key — shown only once at creation
pub key: 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,
}

View File

@@ -3,7 +3,7 @@ use sqlx::PgPool;
use std::sync::Arc;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::{ApFederationConfig, FederationData};
use activitypub_base::service::ActivityPubService;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use event_transport::EventPublisherAdapter;
use nats::NatsTransport;
@@ -16,7 +16,7 @@ use crate::config::Config;
/// Everything the binary needs to start serving.
pub struct Infrastructure {
pub state: AppState,
pub fed_config: ApFederationConfig,
pub ap_service: Arc<ActivityPubService>,
}
struct NoOpEventPublisher;
@@ -61,24 +61,26 @@ pub async fn build(cfg: &Config) -> Infrastructure {
};
// 3. ActivityPub federation
let fed_data = FederationData::new(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(
pool.clone(),
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(),
)),
Arc::new(ThoughtsObjectHandler::new(
Arc::new(PgActivityPubRepository::new(pool.clone())),
&cfg.base_url,
)),
cfg.base_url.clone(),
cfg.allow_registration,
"thoughts".to_string(),
None,
);
let fed_config = ApFederationConfig::new(fed_data, cfg.debug)
cfg.allow_registration,
"thoughts".to_string(),
cfg.debug,
None,
)
.await
.expect("Failed to build federation config");
.expect("Failed to build ActivityPubService"),
);
// 4. Application state
let state = AppState {
@@ -107,7 +109,8 @@ pub async fn build(cfg: &Config) -> Infrastructure {
)),
hasher: Arc::new(auth::Argon2PasswordHasher),
events: event_publisher,
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
};
Infrastructure { state, fed_config }
Infrastructure { state, ap_service }
}

View File

@@ -67,7 +67,7 @@ async fn main() {
"/users/{username}/following",
axum::routing::get(following_handler),
)
.layer(infra.fed_config.middleware());
.layer(infra.ap_service.federation_config().middleware());
let base = presentation::routes::router()
.merge(ap_router)

View File

@@ -12,6 +12,8 @@ pub enum DomainError {
Conflict(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("external service error: {0}")]
ExternalService(String),
#[error("internal error: {0}")]
Internal(String),
}

View File

@@ -8,5 +8,6 @@ pub struct RemoteActor {
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>,
}

View File

@@ -194,6 +194,12 @@ pub trait RemoteActorRepository: Send + Sync {
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError>;
}
#[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>;
}
#[async_trait]
pub trait FeedRepository: Send + Sync {
async fn home_feed(

View File

@@ -534,6 +534,21 @@ impl RemoteActorRepository for TestStore {
}
}
#[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(())
}
}
#[async_trait]
impl FeedRepository for TestStore {
async fn home_feed(
@@ -767,6 +782,32 @@ mod ap_repo_tests {
}
}
#[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();
}
}
#[cfg(test)]
mod search_tests {
use super::*;

View File

@@ -28,6 +28,9 @@ impl IntoResponse for ApiError {
Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()),
Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m),
Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m),
Self::Domain(DomainError::ExternalService(_)) => {
(StatusCode::BAD_GATEWAY, "external service error".into())
}
Self::Domain(DomainError::Internal(_)) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".into(),

View File

@@ -0,0 +1,130 @@
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse};
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
#[derive(Deserialize)]
pub struct LookupQuery {
pub handle: String,
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use axum::{
body::Body,
http::{Request, StatusCode},
routing::{get, post},
Router,
};
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
testing::TestStore,
value_objects::{PasswordHash, UserId},
};
use std::sync::Arc;
use tower::ServiceExt;
struct NoOpAuth;
impl AuthService for NoOpAuth {
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
Err(DomainError::Internal("noop".into()))
}
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized)
}
}
struct NoOpHasher;
#[async_trait]
impl PasswordHasher for NoOpHasher {
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
Err(DomainError::Internal("noop".into()))
}
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
Ok(false)
}
}
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(),
}
}
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 req = Request::builder()
.uri("/federation/lookup?handle=%40alice%40example.com")
.body(Body::empty())
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn follow_remote_without_auth_returns_401() {
let req = Request::builder()
.method("POST")
.uri("/federation/follow")
.header("content-type", "application/json")
.body(Body::from(r#"{"handle":"@alice@example.com"}"#))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}

View File

@@ -1,5 +1,6 @@
pub mod api_keys;
pub mod auth;
pub mod federation;
pub mod feed;
pub mod health;
pub mod notifications;

View File

@@ -92,7 +92,13 @@ pub fn router() -> Router<AppState> {
"/api-keys",
get(api_keys::get_api_keys).post(api_keys::post_api_key),
)
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler))
// federation
.route("/federation/lookup", get(federation::lookup_handler))
.route(
"/federation/follow",
post(federation::follow_remote_handler),
);
openapi::serve(api_routes)
}

View File

@@ -19,4 +19,5 @@ pub struct AppState {
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
pub federation: Arc<dyn FederationActionPort>,
}

View 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: &quot;{query}&quot;
</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 ✅

View File

@@ -0,0 +1,81 @@
# Remote Actor Search & Follow
Allows local users to search for and follow users on other ActivityPub instances (e.g. `@user@mastodon.social`) directly from the existing search page.
## Architecture
Approach A: new `FederationActionPort` domain trait + dedicated `/federation/*` REST endpoints. Keeps hexagonal arch intact — presentation has no dep on `activitypub-base`.
## Domain changes
**`domain/src/models/remote_actor.rs`** — add `avatar_url: Option<String>`
**`domain/src/errors.rs`** — add `ExternalService(String)` variant
**`domain/src/ports.rs`** — new trait:
```rust
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>;
}
```
## activitypub-base impl
`impl domain::ports::FederationActionPort for ActivityPubService` in `service.rs`:
- `lookup_actor`: calls `webfinger_resolve_actor(handle, &data)` → maps `DbActor` to `domain::RemoteActor`
- `follow_remote`: delegates to existing `self.follow(local_user_id.inner(), handle)` (already handles WebFinger + Follow activity + federation DB record)
## Bootstrap refactor
`factory.rs` currently builds `FederationData` + `ApFederationConfig` directly. Switch to `ActivityPubService::new(...)` which creates both internally. `Infrastructure` holds `Arc<ActivityPubService>` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`.
`AppState` gets one new field:
```rust
pub federation: Arc<dyn FederationActionPort>,
```
Wired to `Arc::clone(&ap_service)` in factory.
## REST endpoints
**`api-types/src/responses.rs`** — new:
```rust
pub struct RemoteActorResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
}
```
**`presentation/src/handlers/federation.rs`** (new file):
| Method | Path | Auth | Body | Response |
|--------|------|------|------|----------|
| GET | `/federation/lookup?handle=@user@instance.tld` | none | — | `RemoteActorResponse` |
| POST | `/federation/follow` | bearer | `{"handle":"@user@instance.tld"}` | 204 |
Mounted in `routes.rs` under `/federation`.
Error mapping: `DomainError::ExternalService` → 502, `DomainError::NotFound` → 404.
## Frontend
**`lib/api.ts`**:
- `RemoteActorSchema` + `RemoteActor` type
- `lookupRemoteActor(handle, token)``GET /federation/lookup?handle=...`
- `followRemoteUser(handle, token)``POST /federation/follow`
**`app/search/page.tsx`**:
- Detect `@user@instance.tld` via regex `/^@[\w.-]+@[\w.-]+\.\w+$/`
- If matches: call `lookupRemoteActor` in parallel with local search
- Pass remote actor result to component; show in Users tab above local results
**`components/remote-user-card.tsx`** (new client component):
- Displays avatar, handle, display name
- Follow button calls `followRemoteUser(handle, token)`
- No unfollow needed for MVP (remote following status not tracked locally)

View File

@@ -1,9 +1,12 @@
import { cookies } from "next/headers";
import { getMe, search, User } from "@/lib/api";
import { getMe, search, lookupRemoteActor, User } 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 }>;
}
@@ -24,8 +27,11 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
);
}
const [results, me] = await Promise.all([
search(query, token).catch(() => null),
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,
]);
@@ -45,7 +51,18 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</p>
</header>
<main>
{results ? (
{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">

View File

@@ -0,0 +1,57 @@
"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 src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />
<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>
);
}

View File

@@ -15,6 +15,14 @@ export const UserSchema = z.object({
export const MeSchema = UserSchema;
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>;
export const ThoughtSchema = z.object({
id: z.string().uuid(),
content: z.string(),
@@ -208,6 +216,22 @@ export const followUser = (username: string, token: string) =>
export const unfollowUser = (username: string, token: string) =>
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
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
);
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
apiFetch(
`/users?page=${page}&per_page=${pageSize}`,