Files
thoughts/docs/superpowers/plans/2026-05-14-api-cleanup.md

33 KiB

REST API Cleanup 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: Rename routes, unify local/remote follow, add content negotiation at GET /users/{username}, and switch notification state changes to PATCH — no new features, pure cleanup.

Architecture: The domain FederationActionPort gains actor_json so the presentation layer can serve AP actor JSON without depending on activitypub-base. Content negotiation happens in a single handler that inspects the Accept header. The unified follow handler detects @ in the path param to route local vs remote. All route string changes land in routes.rs and main.rs.

Tech Stack: Rust (axum, domain ports), Next.js 15 (App Router), TypeScript, Zod.


File Map

Action Path Change
Modify crates/domain/src/ports.rs Add actor_json to FederationActionPort
Modify crates/domain/src/testing.rs Add actor_json to TestStore impl + test
Modify crates/adapters/activitypub-base/src/service.rs Impl actor_json; fix handle format in lookup_actor
Modify crates/api-types/src/requests.rs Add NotificationUpdateRequest; remove FollowRemoteRequest
Modify crates/presentation/src/handlers/notifications.rs Replace POST handlers with PATCH
Modify crates/presentation/src/handlers/users.rs Content negotiation in get_user; move lookup_handler from federation; rename get_me_following_list
Modify crates/presentation/src/handlers/social.rs Unified post_follow; delete_follow rejects remote; fix OpenAPI {id}{username}
Delete crates/presentation/src/handlers/federation.rs Both handlers gone: lookup_handlerusers.rs; follow_remote_handler → deleted
Modify crates/presentation/src/handlers/mod.rs Remove pub mod federation;
Modify crates/presentation/src/routes.rs All route string changes
Modify crates/bootstrap/src/main.rs Remove /users/{username} from AP router
Modify thoughts-frontend/lib/api.ts URL/method updates + new notification functions
Modify thoughts-frontend/components/remote-user-card.tsx followRemoteUserfollowUser

Task 1: Domain — add actor_json to FederationActionPort

Files:

  • Modify: crates/domain/src/ports.rs

  • Modify: crates/domain/src/testing.rs

  • Step 1: Add actor_json to the trait

Read crates/domain/src/ports.rs. In the FederationActionPort trait block, add the new method:

#[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 fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
}
  • Step 2: Write the failing test

At the bottom of the federation_port_tests module in crates/domain/src/testing.rs, add:

#[tokio::test]
async fn test_store_actor_json_returns_not_found() {
    let store = TestStore::default();
    let err = store.actor_json(&UserId::new()).await.unwrap_err();
    assert!(matches!(err, DomainError::NotFound));
}
  • Step 3: Run to see it fail
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10

Expected: compile error — actor_json not in TestStore's FederationActionPort impl.

  • Step 4: Implement actor_json on TestStore

In crates/domain/src/testing.rs, inside impl FederationActionPort for TestStore, add:

async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
    Err(DomainError::NotFound)
}
  • Step 5: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10

Expected: all 3 tests pass.

  • Step 6: Compile check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5
  • Step 7: Commit
cd /mnt/drive/dev/thoughts
git add crates/domain/src/ports.rs crates/domain/src/testing.rs
git commit -m "feat(domain): add actor_json to FederationActionPort"

Task 2: activitypub-base — implement actor_json + fix handle format

Files:

  • Modify: crates/adapters/activitypub-base/src/service.rs

  • Step 1: Add compile-time assert

In crates/adapters/activitypub-base/src/tests/service.rs, the existing _assert_impl_federation_action_port function will now fail to compile because actor_json is missing. Run to confirm:

cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10

Expected: error about missing actor_json impl.

  • Step 2: Implement actor_json in the FederationActionPort impl

Read crates/adapters/activitypub-base/src/service.rs. In the impl domain::ports::FederationActionPort for ActivityPubService block, add after follow_remote:

async fn actor_json(
    &self,
    user_id: &domain::value_objects::UserId,
) -> Result<String, domain::errors::DomainError> {
    ActivityPubService::actor_json(self, &user_id.as_uuid().to_string())
        .await
        .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}

Note: ActivityPubService::actor_json is the existing inherent method at line ~210 that takes &str. Calling it as ActivityPubService::actor_json(self, ...) avoids ambiguity with the trait method.

  • Step 3: Fix lookup_actor to return full user@domain handle

In the same file, find the lookup_actor impl. Currently it sets handle: actor.username.clone() (just the preferred_username). Replace the Ok(...) block with:

let domain_str = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain_str);

Ok(domain::models::remote_actor::RemoteActor {
    url: actor.ap_id.to_string(),
    handle: full_handle,
    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,
})
  • Step 4: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5

Expected: no errors.

  • Step 5: Full workspace check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5
  • Step 6: Commit
cd /mnt/drive/dev/thoughts
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(activitypub-base): impl actor_json port; return full user@domain handle from lookup"

Task 3: Notification handlers — PATCH

Files:

  • Modify: crates/api-types/src/requests.rs

  • Modify: crates/presentation/src/handlers/notifications.rs

  • Step 1: Add NotificationUpdateRequest and remove FollowRemoteRequest

Read crates/api-types/src/requests.rs. Remove the FollowRemoteRequest struct (it was only used by the federation handler being deleted). Add:

#[derive(serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct NotificationUpdateRequest {
    pub read: bool,
}
  • Step 2: Write failing tests

Add to crates/presentation/src/handlers/notifications.rs (inside a #[cfg(test)] mod tests block at the bottom, following the same pattern as federation.rs tests — use TestStore and tower::ServiceExt::oneshot):

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, header},
        routing::{get, patch},
        Router,
    };
    use domain::testing::TestStore;
    use std::sync::Arc;
    use tower::ServiceExt;

    // Re-use the same NoOpAuth/NoOpHasher stubs from federation.rs tests pattern:
    // Check crates/presentation/src/handlers/federation.rs for the exact stub code
    // and copy it here (NoOpAuth implementing AuthService, NoOpHasher implementing PasswordHasher).

    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("/notifications", patch(mark_all_read))
            .route("/notifications/:id", patch(mark_notification_read))
            .with_state(make_state())
    }

    #[tokio::test]
    async fn patch_notification_without_auth_returns_401() {
        let resp = app()
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/notifications/00000000-0000-0000-0000-000000000001")
                    .header(header::CONTENT_TYPE, "application/json")
                    .body(Body::from(r#"{"read":true}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), 401);
    }

    #[tokio::test]
    async fn patch_all_without_auth_returns_401() {
        let resp = app()
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/notifications")
                    .header(header::CONTENT_TYPE, "application/json")
                    .body(Body::from(r#"{"read":true}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), 401);
    }
}

Note: copy the NoOpAuth and NoOpHasher struct definitions from crates/presentation/src/handlers/federation.rs — they are defined inline in the test module there.

  • Step 3: Run to see compile/test failure
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -20

Expected: compile error — mark_notification_read and mark_all_read don't accept JSON body yet.

  • Step 4: Replace the POST handlers with PATCH handlers

Replace the full content of crates/presentation/src/handlers/notifications.rs with:

use api_types::requests::NotificationUpdateRequest;
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
use application::use_cases::notifications::{
    list_notifications as uc_list_notifications, mark_all_notifications_read,
    mark_notification_read as uc_mark_notification_read,
};
use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use domain::{models::feed::PageParams, value_objects::NotificationId};
use uuid::Uuid;

pub async fn list_notifications(
    State(s): State<AppState>,
    AuthUser(uid): AuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
    let page = PageParams { page: 1, per_page: 20 };
    let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
    Ok(Json(serde_json::json!({
        "total": result.total,
        "unread": result.items.iter().filter(|n| !n.read).count()
    })))
}

pub async fn mark_notification_read(
    State(s): State<AppState>,
    AuthUser(uid): AuthUser,
    Path(id): Path<Uuid>,
    Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
    if body.read {
        uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
    }
    Ok(StatusCode::NO_CONTENT)
}

pub async fn mark_all_read(
    State(s): State<AppState>,
    AuthUser(uid): AuthUser,
    Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
    if body.read {
        mark_all_notifications_read(&*s.notifications, &uid).await?;
    }
    Ok(StatusCode::NO_CONTENT)
}

#[cfg(test)]
mod tests {
    // ... (same test block from Step 2)
}
  • Step 5: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -10

Expected: both tests pass (401 without auth).

  • Step 6: Compile check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -10

If there are errors about FollowRemoteRequest still being used (e.g. in federation.rs), that's fine — Task 5 deletes that file.

  • Step 7: Commit
cd /mnt/drive/dev/thoughts
git add crates/api-types/src/requests.rs crates/presentation/src/handlers/notifications.rs
git commit -m "refactor(api): notification state changes use PATCH"

Task 4: Users handler — content negotiation + lookup move

Files:

  • Modify: crates/presentation/src/handlers/users.rs

  • Step 1: Write failing tests

Add a #[cfg(test)] mod tests block at the bottom of crates/presentation/src/handlers/users.rs. The NoOpAuth/NoOpHasher pattern is the same as in Task 3. Add:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, header},
        routing::get,
        Router,
    };
    use domain::testing::TestStore;
    use std::sync::Arc;
    use tower::ServiceExt;

    // (copy NoOpAuth, NoOpHasher structs from federation.rs test module)

    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("/users/:username", get(get_user))
            .route("/users/lookup", get(lookup_handler))
            .with_state(make_state())
    }

    #[tokio::test]
    async fn get_unknown_user_returns_404() {
        let resp = app()
            .oneshot(Request::builder().uri("/users/nobody").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 404);
    }

    #[tokio::test]
    async fn get_user_with_ap_accept_calls_actor_json_returns_404_when_not_found() {
        // TestStore.actor_json returns NotFound, so AP requests to unknown users → 404
        let resp = app()
            .oneshot(
                Request::builder()
                    .uri("/users/nobody")
                    .header(header::ACCEPT, "application/activity+json")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), 404);
    }

    #[tokio::test]
    async fn lookup_unknown_handle_returns_404() {
        let resp = app()
            .oneshot(
                Request::builder()
                    .uri("/users/lookup?handle=%40alice%40example.com")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), 404);
    }
}
  • Step 2: Run to confirm tests compile but need implementation changes
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -20

Expected: compile errors until we add lookup_handler to users.rs and modify get_user.

  • Step 3: Update users.rs

Read the full crates/presentation/src/handlers/users.rs.

3a. Add new imports at the top:

use axum::http::{HeaderMap, header};
use axum::response::{IntoResponse, Response};
use api_types::responses::RemoteActorResponse;

3b. Replace the get_user handler (currently returns Result<Json<UserResponse>, ApiError>) with:

pub async fn get_user(
    State(s): State<AppState>,
    Path(username): Path<String>,
    OptionalAuthUser(viewer): OptionalAuthUser,
    headers: HeaderMap,
) -> Result<Response, ApiError> {
    let user = get_user_by_username(&*s.users, &username).await?;

    let accept = headers
        .get(header::ACCEPT)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if accept.contains("application/activity+json") {
        let json = s.federation.actor_json(&user.id).await?;
        Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
    } else {
        let is_followed = if let Some(viewer_id) = viewer {
            s.follows.find(&viewer_id, &user.id).await?.is_some()
        } else {
            false
        };
        let mut resp = to_user_response(&user);
        resp.is_followed_by_viewer = is_followed;
        Ok(Json(resp).into_response())
    }
}

3c. Rename get_me_following_listget_me_following (just the function name — update it in place):

Find pub async fn get_me_following_list and rename to pub async fn get_me_following.

3d. Add LookupQuery and lookup_handler from federation.rs:

#[derive(serde::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,
    }))
}
  • Step 4: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -10

Expected: all 3 tests pass.

  • Step 5: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10

There will be errors about federation.rs still defining lookup_handler (duplicate) — that's resolved in Task 5 when we delete federation.rs. For now, just ensure users.rs itself compiles.

  • Step 6: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/handlers/users.rs
git commit -m "refactor(users): content negotiation at GET /users/{username}; move lookup handler"

Task 5: Social handler cleanup + delete federation.rs

Files:

  • Modify: crates/presentation/src/handlers/social.rs

  • Delete: crates/presentation/src/handlers/federation.rs

  • Modify: crates/presentation/src/handlers/mod.rs

  • Step 1: Write failing tests for unified follow

Add a #[cfg(test)] mod tests block at the bottom of crates/presentation/src/handlers/social.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::Request,
        routing::{delete, post},
        Router,
    };
    use domain::testing::TestStore;
    use std::sync::Arc;
    use tower::ServiceExt;

    // (copy NoOpAuth, NoOpHasher structs from federation.rs test module)

    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("/users/:username/follow", post(post_follow).delete(delete_follow))
            .with_state(make_state())
    }

    #[tokio::test]
    async fn follow_without_auth_returns_401() {
        let resp = app()
            .oneshot(Request::builder().method("POST").uri("/users/alice/follow").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 401);
    }

    #[tokio::test]
    async fn unfollow_remote_handle_without_auth_returns_401() {
        let resp = app()
            .oneshot(Request::builder().method("DELETE").uri("/users/alice@example.com/follow").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 401);
    }
}
  • Step 2: Run to see compile state
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -15
  • Step 3: Update post_follow to unify local and remote follows

In crates/presentation/src/handlers/social.rs, replace post_follow with:

#[utoipa::path(
    post, path = "/users/{username}/follow",
    params(("username" = String, Path, description = "Username or user@domain handle")),
    responses((status = 204, description = "Following")),
    security(("bearer_auth" = []))
)]
pub async fn post_follow(
    State(s): State<AppState>,
    AuthUser(uid): AuthUser,
    Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
    if username.contains('@') {
        s.federation.follow_remote(&uid, &username).await?;
    } else {
        let target = get_user_by_username(&*s.users, &username).await?;
        follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
    }
    Ok(StatusCode::NO_CONTENT)
}
  • Step 4: Update delete_follow to reject remote handles

Replace delete_follow with:

#[utoipa::path(
    delete, path = "/users/{username}/follow",
    params(("username" = String, Path, description = "Username")),
    responses((status = 204, description = "Unfollowed")),
    security(("bearer_auth" = []))
)]
pub async fn delete_follow(
    State(s): State<AppState>,
    AuthUser(uid): AuthUser,
    Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
    if username.contains('@') {
        return Err(ApiError::BadRequest("remote unfollow not yet supported".into()));
    }
    let target = get_user_by_username(&*s.users, &username).await?;
    unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
    Ok(StatusCode::NO_CONTENT)
}
  • Step 5: Fix {id}{username} in OpenAPI annotations for block handlers

In social.rs, update the #[utoipa::path] annotations on post_block and delete_block:

  • Change path = "/users/{id}/block"path = "/users/{username}/block"
  • Change ("id" = uuid::Uuid, Path, description = "User ID")("username" = String, Path, description = "Username")

Same for post_follow and delete_follow (already done in steps above).

  • Step 6: Delete federation.rs and update mod.rs

Delete the file:

rm /mnt/drive/dev/thoughts/crates/presentation/src/handlers/federation.rs

In crates/presentation/src/handlers/mod.rs, remove the line:

pub mod federation;
  • Step 7: Run tests
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -10

Expected: both tests pass (401 without auth).

  • Step 8: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10

Expected: no errors (all federation:: references removed from routes in next task — routes.rs will fail until Task 6).

  • Step 9: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/handlers/social.rs \
        crates/presentation/src/handlers/mod.rs
git rm crates/presentation/src/handlers/federation.rs
git commit -m "refactor(social): unified follow handler; remove federation handler module"

Task 6: Routes + bootstrap

Files:

  • Modify: crates/presentation/src/routes.rs

  • Modify: crates/bootstrap/src/main.rs

  • Step 1: Replace routes.rs with the cleaned-up route table

Read crates/presentation/src/routes.rs first. Replace the full api_routes builder chain with:

pub fn router() -> Router<AppState> {
    let api_routes = Router::new()
        // health
        .route("/health", get(health::health_handler))
        // auth
        .route("/auth/register", post(auth::post_register))
        .route("/auth/login", post(auth::post_login))
        // users — static before parameterised
        .route("/users", get(users::get_users))
        .route("/users/count", get(users::get_user_count))
        .route("/users/lookup", get(users::lookup_handler))
        .route(
            "/users/me",
            get(users::get_me).patch(users::patch_profile),
        )
        .route("/users/me/following", get(users::get_me_following))
        .route("/users/me/top-friends", put(social::put_top_friends))
        .route("/users/{username}", get(users::get_user))
        .route(
            "/users/{username}/top-friends",
            get(social::get_top_friends_handler),
        )
        .route(
            "/users/{username}/follow",
            post(social::post_follow).delete(social::delete_follow),
        )
        .route(
            "/users/{username}/block",
            post(social::post_block).delete(social::delete_block),
        )
        .route(
            "/users/{username}/followers",
            get(feed::get_followers_handler),
        )
        .route(
            "/users/{username}/following",
            get(feed::get_following_handler),
        )
        .route(
            "/users/{username}/thoughts",
            get(feed::user_thoughts_handler),
        )
        // thoughts
        .route("/thoughts", post(thoughts::post_thought))
        .route(
            "/thoughts/{id}",
            get(thoughts::get_thought_handler)
                .patch(thoughts::patch_thought)
                .delete(thoughts::delete_thought_handler),
        )
        .route("/thoughts/{id}/thread", get(thoughts::get_thread_handler))
        // likes & boosts
        .route(
            "/thoughts/{id}/like",
            post(social::post_like).delete(social::delete_like),
        )
        .route(
            "/thoughts/{id}/boost",
            post(social::post_boost).delete(social::delete_boost),
        )
        // feeds
        .route("/feed", get(feed::home_feed))
        .route("/feed/public", get(feed::public_feed))
        .route("/search", get(feed::search_handler))
        .route("/tags/popular", get(feed::get_popular_tags))
        .route("/tags/{name}", get(feed::tag_thoughts_handler))
        // notifications
        .route(
            "/notifications",
            get(notifications::list_notifications).patch(notifications::mark_all_read),
        )
        .route(
            "/notifications/{id}",
            patch(notifications::mark_notification_read),
        )
        // api keys
        .route(
            "/api-keys",
            get(api_keys::get_api_keys).post(api_keys::post_api_key),
        )
        .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));

    openapi::serve(api_routes)
}

Make sure patch is imported: use axum::routing::{delete, get, patch, post, put};.

  • Step 2: Remove /users/{username} from the AP router in main.rs

Read crates/bootstrap/src/main.rs. In the ap_router builder, remove this line:

.route("/users/{username}", axum::routing::get(actor_handler))

Also remove the actor_handler import from activitypub_base if it's no longer used anywhere in main.rs:

use activitypub_base::{
    actor_handler::actor_handler,  // ← remove this line
    followers_handler::{followers_handler, following_handler},
    ...
};
  • Step 3: Full compile check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -15

Expected: no errors. If actor_handler is still imported but unused, remove it.

  • Step 4: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -10

Expected: all tests pass.

  • Step 5: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/routes.rs crates/bootstrap/src/main.rs
git commit -m "refactor(routes): clean RESTful route table; content negotiation at /users/{username}"

Task 7: Frontend — api.ts + remote-user-card.tsx

Files:

  • Modify: thoughts-frontend/lib/api.ts

  • Modify: thoughts-frontend/components/remote-user-card.tsx

  • Step 1: Update all changed URLs and methods in api.ts

Read thoughts-frontend/lib/api.ts. Make these targeted edits:

getUserProfile — change URL:

export const getUserProfile = (username: string, token: string | null) =>
  apiFetch(`/users/${username}`, {}, UserSchema, token);

getFollowersList — change URL:

export const getFollowersList = (username: string, token: string | null) =>
  apiFetch(`/users/${username}/followers`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);

getFollowingList — change URL:

export const getFollowingList = (username: string, token: string | null) =>
  apiFetch(`/users/${username}/following`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);

getMeFollowingList — change URL:

export const getMeFollowingList = (token: string) =>
  apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);

lookupRemoteActor — change URL:

export const lookupRemoteActor = (handle: string, token: string | null) =>
  apiFetch(
    `/users/lookup?handle=${encodeURIComponent(handle)}`,
    {},
    RemoteActorSchema,
    token
  );

Delete followRemoteUser — remove this entire function (unified follow now uses followUser with the full user@domain handle):

// DELETE this:
export const followRemoteUser = (handle: string, token: string) =>
  apiFetch(
    `/federation/follow`,
    { method: "POST", body: JSON.stringify({ handle }) },
    z.null(),
    token
  );

Add markNotificationRead:

export const markNotificationRead = (id: string, token: string) =>
  apiFetch(
    `/notifications/${id}`,
    { method: "PATCH", body: JSON.stringify({ read: true }) },
    z.null(),
    token
  );

Add markAllNotificationsRead:

export const markAllNotificationsRead = (token: string) =>
  apiFetch(
    "/notifications",
    { method: "PATCH", body: JSON.stringify({ read: true }) },
    z.null(),
    token
  );
  • Step 2: Update remote-user-card.tsx

Read thoughts-frontend/components/remote-user-card.tsx. Change the follow button's action from followRemoteUser to followUser:

Replace:

import { followRemoteUser, RemoteActor } from "@/lib/api";

With:

import { followUser, RemoteActor } from "@/lib/api";

Replace:

await followRemoteUser(actor.handle, token);

With:

await followUser(actor.handle, token);

This works because actor.handle is now the full user@domain format (e.g. gabrielkaszewski@mastodon.social) from the fixed lookup_actor, and followUser calls POST /users/gabrielkaszewski@mastodon.social/follow, which the unified handler detects as a remote follow.

  • Step 3: Type-check
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20

Expected: no errors. If any page references followRemoteUser, update it to followUser.

  • Step 4: Commit
cd /mnt/drive/dev/thoughts
git add thoughts-frontend/lib/api.ts thoughts-frontend/components/remote-user-card.tsx
git commit -m "refactor(frontend): update API client to match cleaned REST routes"

Self-Review

Spec coverage:

  • GET /users/{username} content negotiation — Tasks 1, 2, 4, 6
  • GET /users/lookup moved from /federation/lookup — Tasks 4, 6
  • POST /users/{username}/follow unified — Task 5, 6
  • DELETE /users/{username}/follow 400 for remote — Task 5
  • {id}{username} param rename in follow/block — Tasks 5, 6
  • followers/following route rename — Task 6
  • me/following rename — Tasks 4, 6
  • PATCH /notifications/{id} — Tasks 3, 6
  • PATCH /notifications bulk — Tasks 3, 6
  • PUT /users/me removed — Task 6
  • POST /federation/follow removed — Tasks 5, 6
  • Frontend api.ts updates — Task 7
  • remote-user-card.tsx followUser — Task 7
  • Handle format fix (user@domain) in lookup_actor — Task 2

Placeholder scan: None found.

Type consistency:

  • actor_json(&self, user_id: &UserId) defined in Task 1, implemented in Task 2, called in Task 4
  • get_me_following renamed in Task 4, referenced in Task 6 routes
  • lookup_handler defined in Task 4 (users.rs), referenced in Task 6 routes as users::lookup_handler
  • NotificationUpdateRequest defined in Task 3 (api-types), used in Task 3 (notifications.rs)
  • followUser(actor.handle, token)actor.handle is full user@domain after Task 2 fix