diff --git a/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md b/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md new file mode 100644 index 0000000..365655e --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-remote-actor-search-follow-design.md @@ -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` + +**`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; + 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` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`. + +`AppState` gets one new field: + +```rust +pub federation: Arc, +``` + +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, + pub avatar_url: Option, + 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)