Compare commits
11 Commits
8602614e7c
...
dbd891d60d
| Author | SHA1 | Date | |
|---|---|---|---|
| dbd891d60d | |||
| baf8b57b6d | |||
| a7a331858d | |||
| 31487882e0 | |||
| a08bb3d698 | |||
| 1d50b54227 | |||
| fce819be7f | |||
| 0e45707d7e | |||
| 82f8772104 | |||
| 8eb59bfac6 | |||
| 62970d519a |
@@ -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)]
|
#[cfg(test)]
|
||||||
#[path = "tests/service.rs"]
|
#[path = "tests/service.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
fn _assert_impl_federation_action_port()
|
||||||
|
where
|
||||||
|
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::repository::{Follower, FollowerStatus, RemoteActor};
|
use crate::repository::{Follower, FollowerStatus, RemoteActor};
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ impl PgRemoteActorRepository {
|
|||||||
impl RemoteActorRepository for PgRemoteActorRepository {
|
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||||
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at)
|
"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)
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
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,
|
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.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(|_| ())
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +38,13 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
|||||||
inbox_url: String,
|
inbox_url: String,
|
||||||
shared_inbox_url: Option<String>,
|
shared_inbox_url: Option<String>,
|
||||||
public_key: String,
|
public_key: String,
|
||||||
|
avatar_url: Option<String>,
|
||||||
last_fetched_at: DateTime<Utc>,
|
last_fetched_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, Row>(
|
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
|
).bind(url).fetch_optional(&self.pool).await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.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 }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,3 +80,9 @@ pub struct SearchQuery {
|
|||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
pub per_page: Option<u64>,
|
pub per_page: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FollowRemoteRequest {
|
||||||
|
pub handle: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,3 +87,12 @@ pub struct CreatedApiKeyResponse {
|
|||||||
/// Raw API key — shown only once at creation
|
/// Raw API key — shown only once at creation
|
||||||
pub key: String,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use sqlx::PgPool;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use activitypub::ThoughtsObjectHandler;
|
use activitypub::ThoughtsObjectHandler;
|
||||||
use activitypub_base::{ApFederationConfig, FederationData};
|
use activitypub_base::service::ActivityPubService;
|
||||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||||
use event_transport::EventPublisherAdapter;
|
use event_transport::EventPublisherAdapter;
|
||||||
use nats::NatsTransport;
|
use nats::NatsTransport;
|
||||||
@@ -16,7 +16,7 @@ use crate::config::Config;
|
|||||||
/// Everything the binary needs to start serving.
|
/// Everything the binary needs to start serving.
|
||||||
pub struct Infrastructure {
|
pub struct Infrastructure {
|
||||||
pub state: AppState,
|
pub state: AppState,
|
||||||
pub fed_config: ApFederationConfig,
|
pub ap_service: Arc<ActivityPubService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoOpEventPublisher;
|
struct NoOpEventPublisher;
|
||||||
@@ -61,24 +61,26 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. ActivityPub federation
|
// 3. ActivityPub federation
|
||||||
let fed_data = FederationData::new(
|
let ap_service = Arc::new(
|
||||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
ActivityPubService::new(
|
||||||
Arc::new(PostgresApUserRepository::new(
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
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.base_url.clone(),
|
||||||
)),
|
cfg.allow_registration,
|
||||||
Arc::new(ThoughtsObjectHandler::new(
|
"thoughts".to_string(),
|
||||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
cfg.debug,
|
||||||
&cfg.base_url,
|
None,
|
||||||
)),
|
)
|
||||||
cfg.base_url.clone(),
|
|
||||||
cfg.allow_registration,
|
|
||||||
"thoughts".to_string(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let fed_config = ApFederationConfig::new(fed_data, cfg.debug)
|
|
||||||
.await
|
.await
|
||||||
.expect("Failed to build federation config");
|
.expect("Failed to build ActivityPubService"),
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Application state
|
// 4. Application state
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -107,7 +109,8 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
)),
|
)),
|
||||||
hasher: Arc::new(auth::Argon2PasswordHasher),
|
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||||
events: event_publisher,
|
events: event_publisher,
|
||||||
|
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
||||||
};
|
};
|
||||||
|
|
||||||
Infrastructure { state, fed_config }
|
Infrastructure { state, ap_service }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async fn main() {
|
|||||||
"/users/{username}/following",
|
"/users/{username}/following",
|
||||||
axum::routing::get(following_handler),
|
axum::routing::get(following_handler),
|
||||||
)
|
)
|
||||||
.layer(infra.fed_config.middleware());
|
.layer(infra.ap_service.federation_config().middleware());
|
||||||
|
|
||||||
let base = presentation::routes::router()
|
let base = presentation::routes::router()
|
||||||
.merge(ap_router)
|
.merge(ap_router)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub enum DomainError {
|
|||||||
Conflict(String),
|
Conflict(String),
|
||||||
#[error("invalid input: {0}")]
|
#[error("invalid input: {0}")]
|
||||||
InvalidInput(String),
|
InvalidInput(String),
|
||||||
|
#[error("external service error: {0}")]
|
||||||
|
ExternalService(String),
|
||||||
#[error("internal error: {0}")]
|
#[error("internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ pub struct RemoteActor {
|
|||||||
pub inbox_url: String,
|
pub inbox_url: String,
|
||||||
pub shared_inbox_url: Option<String>,
|
pub shared_inbox_url: Option<String>,
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
pub last_fetched_at: DateTime<Utc>,
|
pub last_fetched_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,12 @@ pub trait RemoteActorRepository: Send + Sync {
|
|||||||
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError>;
|
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]
|
#[async_trait]
|
||||||
pub trait FeedRepository: Send + Sync {
|
pub trait FeedRepository: Send + Sync {
|
||||||
async fn home_feed(
|
async fn home_feed(
|
||||||
|
|||||||
@@ -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]
|
#[async_trait]
|
||||||
impl FeedRepository for TestStore {
|
impl FeedRepository for TestStore {
|
||||||
async fn home_feed(
|
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)]
|
#[cfg(test)]
|
||||||
mod search_tests {
|
mod search_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ impl IntoResponse for ApiError {
|
|||||||
Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()),
|
Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()),
|
||||||
Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m),
|
Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m),
|
||||||
Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, 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(_)) => (
|
Self::Domain(DomainError::Internal(_)) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"internal server error".into(),
|
"internal server error".into(),
|
||||||
|
|||||||
130
crates/presentation/src/handlers/federation.rs
Normal file
130
crates/presentation/src/handlers/federation.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod federation;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|||||||
@@ -92,7 +92,13 @@ pub fn router() -> Router<AppState> {
|
|||||||
"/api-keys",
|
"/api-keys",
|
||||||
get(api_keys::get_api_keys).post(api_keys::post_api_key),
|
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)
|
openapi::serve(api_routes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ pub struct AppState {
|
|||||||
pub auth: Arc<dyn AuthService>,
|
pub auth: Arc<dyn AuthService>,
|
||||||
pub hasher: Arc<dyn PasswordHasher>,
|
pub hasher: Arc<dyn PasswordHasher>,
|
||||||
pub events: Arc<dyn EventPublisher>,
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
}
|
}
|
||||||
|
|||||||
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 ✅
|
||||||
@@ -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)
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { cookies } from "next/headers";
|
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 { UserListCard } from "@/components/user-list-card";
|
||||||
|
import { RemoteUserCard } from "@/components/remote-user-card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ThoughtList } from "@/components/thought-list";
|
import { ThoughtList } from "@/components/thought-list";
|
||||||
|
|
||||||
|
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
|
||||||
|
|
||||||
interface SearchPageProps {
|
interface SearchPageProps {
|
||||||
searchParams: Promise<{ q?: string }>;
|
searchParams: Promise<{ q?: string }>;
|
||||||
}
|
}
|
||||||
@@ -24,8 +27,11 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [results, me] = await Promise.all([
|
const isHandle = HANDLE_RE.test(query);
|
||||||
search(query, token).catch(() => null),
|
|
||||||
|
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,
|
token ? getMe(token).catch(() => null) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -45,7 +51,18 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<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">
|
<Tabs defaultValue="thoughts" className="w-full">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="thoughts">
|
<TabsTrigger value="thoughts">
|
||||||
|
|||||||
57
thoughts-frontend/components/remote-user-card.tsx
Normal file
57
thoughts-frontend/components/remote-user-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ export const UserSchema = z.object({
|
|||||||
|
|
||||||
export const MeSchema = UserSchema;
|
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({
|
export const ThoughtSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
@@ -208,6 +216,22 @@ export const followUser = (username: string, token: string) =>
|
|||||||
export const unfollowUser = (username: string, token: string) =>
|
export const unfollowUser = (username: string, token: string) =>
|
||||||
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
|
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) =>
|
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||||
apiFetch(
|
apiFetch(
|
||||||
`/users?page=${page}&per_page=${pageSize}`,
|
`/users?page=${page}&per_page=${pageSize}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user