diff --git a/docs/superpowers/plans/2026-05-14-api-cleanup.md b/docs/superpowers/plans/2026-05-14-api-cleanup.md new file mode 100644 index 0000000..6fba046 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-api-cleanup.md @@ -0,0 +1,1054 @@ +# 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_handler` → `users.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` | `followRemoteUser` → `followUser` | + +--- + +## 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: + +```rust +#[async_trait] +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>; + async fn actor_json(&self, user_id: &UserId) -> Result; +} +``` + +- [ ] **Step 2: Write the failing test** + +At the bottom of the `federation_port_tests` module in `crates/domain/src/testing.rs`, add: + +```rust +#[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** + +```bash +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: + +```rust +async fn actor_json(&self, _user_id: &UserId) -> Result { + Err(DomainError::NotFound) +} +``` + +- [ ] **Step 5: Run tests to confirm pass** + +```bash +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** + +```bash +cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5 +``` + +- [ ] **Step 7: Commit** + +```bash +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: + +```bash +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`: + +```rust +async fn actor_json( + &self, + user_id: &domain::value_objects::UserId, +) -> Result { + 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: + +```rust +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** + +```bash +cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 +``` + +Expected: no errors. + +- [ ] **Step 5: Full workspace check** + +```bash +cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5 +``` + +- [ ] **Step 6: Commit** + +```bash +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: + +```rust +#[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`): + +```rust +#[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** + +```bash +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: + +```rust +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, + AuthUser(uid): AuthUser, +) -> Result, 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, + AuthUser(uid): AuthUser, + Path(id): Path, + Json(body): Json, +) -> Result { + 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, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + 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** + +```bash +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** + +```bash +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** + +```bash +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: + +```rust +#[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** + +```bash +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:** + +```rust +use axum::http::{HeaderMap, header}; +use axum::response::{IntoResponse, Response}; +use api_types::responses::RemoteActorResponse; +``` + +**3b. Replace the `get_user` handler** (currently returns `Result, ApiError>`) with: + +```rust +pub async fn get_user( + State(s): State, + Path(username): Path, + OptionalAuthUser(viewer): OptionalAuthUser, + headers: HeaderMap, +) -> Result { + 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_list` → `get_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`:** + +```rust +#[derive(serde::Deserialize)] +pub struct LookupQuery { + pub handle: String, +} + +pub async fn lookup_handler( + State(s): State, + Query(q): Query, +) -> Result, 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** + +```bash +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** + +```bash +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** + +```bash +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`: + +```rust +#[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** + +```bash +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: + +```rust +#[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, + AuthUser(uid): AuthUser, + Path(username): Path, +) -> Result { + 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: + +```rust +#[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, + AuthUser(uid): AuthUser, + Path(username): Path, +) -> Result { + 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: +```bash +rm /mnt/drive/dev/thoughts/crates/presentation/src/handlers/federation.rs +``` + +In `crates/presentation/src/handlers/mod.rs`, remove the line: +```rust +pub mod federation; +``` + +- [ ] **Step 7: Run tests** + +```bash +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** + +```bash +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** + +```bash +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: + +```rust +pub fn router() -> Router { + 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: + +```rust +.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`: + +```rust +use activitypub_base::{ + actor_handler::actor_handler, // ← remove this line + followers_handler::{followers_handler, following_handler}, + ... +}; +``` + +- [ ] **Step 3: Full compile check** + +```bash +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** + +```bash +cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +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: +```typescript +export const getUserProfile = (username: string, token: string | null) => + apiFetch(`/users/${username}`, {}, UserSchema, token); +``` + +**`getFollowersList`** — change URL: +```typescript +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: +```typescript +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: +```typescript +export const getMeFollowingList = (token: string) => + apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); +``` + +**`lookupRemoteActor`** — change URL: +```typescript +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): +```typescript +// DELETE this: +export const followRemoteUser = (handle: string, token: string) => + apiFetch( + `/federation/follow`, + { method: "POST", body: JSON.stringify({ handle }) }, + z.null(), + token + ); +``` + +**Add `markNotificationRead`**: +```typescript +export const markNotificationRead = (id: string, token: string) => + apiFetch( + `/notifications/${id}`, + { method: "PATCH", body: JSON.stringify({ read: true }) }, + z.null(), + token + ); +``` + +**Add `markAllNotificationsRead`**: +```typescript +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: +```typescript +import { followRemoteUser, RemoteActor } from "@/lib/api"; +``` +With: +```typescript +import { followUser, RemoteActor } from "@/lib/api"; +``` + +Replace: +```typescript +await followRemoteUser(actor.handle, token); +``` +With: +```typescript +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** + +```bash +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** + +```bash +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 ✅