Files
thoughts/docs/superpowers/plans/2026-05-14-remote-actor-search-follow.md

28 KiB

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:

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:

#[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:

#[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:

#[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
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:

#[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
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
cargo check -p domain 2>&1 | tail -10

Expected: no errors.

  • Step 9: Commit
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:

// 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
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:

#[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
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
cargo check -p activitypub-base 2>&1 | tail -10

Expected: no errors.

  • Step 6: Commit
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:

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:

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:

use activitypub_base::service::ActivityPubService;
use domain::ports::FederationActionPort;

Change Infrastructure struct:

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:

// 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:

federation: ap_service.clone() as Arc<dyn FederationActionPort>,

Change the Infrastructure { ... } return to:

Infrastructure { state, ap_service }
  • Step 4: Update main.rs to use ap_service

In crates/bootstrap/src/main.rs, change the middleware line from:

.layer(infra.fed_config.middleware());

to:

.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
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
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:

#[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:

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:

#[derive(serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FollowRemoteRequest {
    pub handle: String,
}
  • Step 4: Run tests to see them fail
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:

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:

pub mod federation;
  • Step 7: Mount routes

In crates/presentation/src/routes.rs, add these two routes inside let api_routes = Router::new():

.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
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
cargo check 2>&1 | tail -15

Expected: no errors.

  • Step 10: Commit
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:

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:

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:

"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
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:

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
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
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.handlefollow_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