Files
thoughts/docs/superpowers/plans/2026-05-14-merge-readiness.md
Gabriel Kaszewski 004bfb427b
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s
feat: implement merge readiness plan to close gaps between v2 and v1
- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`.
- Task 2: Wire follower/following REST routes for user feeds.
- Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`.
- Task 4: Implement popular tags feature with `GET /tags/popular`.
- Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
2026-05-14 16:28:18 +02:00

20 KiB
Raw Blame History

Merge Readiness 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: Close the remaining gaps between v2 and v1 so the new Rust backend can replace the old one. Five tasks: fix feed response hydration, wire missing follower/following routes, add user listing endpoints, add popular tags, harden config (HOST, CORS, rate limiting).

Architecture: All changes are in presentation, domain/ports, adapters/postgres, and bootstrap. No changes to application or worker.


File Map

Task 1 — Feed hydration:
  Modify: crates/presentation/src/handlers/feed.rs     ← add to_thought_response helper, fix 4 handlers
  Modify: crates/presentation/src/handlers/auth.rs     ← move/export to_feed_entry helper if needed

Task 2 — Wire follower/following routes:
  Modify: crates/presentation/src/routes.rs            ← add 2 routes

Task 3 — User listing + count:
  Modify: crates/domain/src/ports.rs                   ← add count() to UserRepository
  Modify: crates/adapters/postgres/src/user.rs         ← implement count()
  Modify: crates/domain/src/testing.rs                 ← add count() to TestStore
  Modify: crates/presentation/src/handlers/users.rs    ← add get_users, get_user_count handlers
  Modify: crates/presentation/src/routes.rs            ← add 2 routes

Task 4 — Popular tags:
  Modify: crates/domain/src/ports.rs                   ← add popular_tags() to TagRepository
  Modify: crates/adapters/postgres/src/tag.rs          ← implement popular_tags()
  Modify: crates/domain/src/testing.rs                 ← add popular_tags() to TestStore
  Modify: crates/presentation/src/handlers/feed.rs     ← add get_popular_tags handler
  Modify: crates/presentation/src/routes.rs            ← add 1 route (before /tags/{name})

Task 5 — Config: HOST, CORS_ORIGINS, RATE_LIMIT:
  Modify: crates/bootstrap/src/config.rs               ← 3 new fields
  Modify: crates/bootstrap/src/main.rs                 ← use HOST, CORS layer, rate limit layer
  Modify: crates/bootstrap/Cargo.toml                  ← add tower-governor
  Modify: .env.example                                  ← document new vars

Task 1: Fix feed response hydration

Files:

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

Problem: home_feed and public_feed return only UUIDs. user_thoughts_handler and tag_thoughts_handler are missing author, in_reply_to_id, sensitive, content_warning, viewer flags. All four need to use ThoughtResponse.

The ThoughtResponse DTO in api-types already has every needed field. FeedEntry in domain already carries like_count, boost_count, reply_count, liked_by_viewer, boosted_by_viewer. The conversion is straightforward.

  • Add to_thought_response helper at the top of feed.rs (after existing imports). This is a private free function:
use api_types::responses::ThoughtResponse;

fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
    ThoughtResponse {
        id: e.thought.id.as_uuid(),
        content: e.thought.content.as_str().to_string(),
        author: crate::handlers::auth::to_user_response(&e.author),
        in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
        visibility: e.thought.visibility.as_str().to_string(),
        content_warning: e.thought.content_warning.clone(),
        sensitive: e.thought.sensitive,
        like_count: e.like_count,
        boost_count: e.boost_count,
        reply_count: e.reply_count,
        liked_by_viewer: e.liked_by_viewer,
        boosted_by_viewer: e.boosted_by_viewer,
        created_at: e.thought.created_at,
        updated_at: e.thought.updated_at,
    }
}
  • Fix home_feed — replace the UUID-only mapping:
pub async fn home_feed(
    State(s): State<AppState>,
    AuthUser(uid): AuthUser,
    Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let page = PageParams { page: q.page(), per_page: q.per_page() };
    let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?;
    Ok(Json(serde_json::json!({
        "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
        "total": result.total,
        "page": result.page,
        "per_page": result.per_page,
    })))
}
  • Fix public_feed — same pattern:
pub async fn public_feed(
    State(s): State<AppState>,
    OptionalAuthUser(viewer): OptionalAuthUser,
    Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let page = PageParams { page: q.page(), per_page: q.per_page() };
    let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?;
    Ok(Json(serde_json::json!({
        "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
        "total": result.total,
        "page": result.page,
        "per_page": result.per_page,
    })))
}
  • Fix user_thoughts_handler — replace the partial mapping with to_thought_response:
pub async fn user_thoughts_handler(
    State(s): State<AppState>,
    Path(username): Path<String>,
    Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let user = get_user_by_username(&*s.users, &username).await?;
    let page = PageParams { page: q.page(), per_page: q.per_page() };
    let result = get_user_feed(&*s.thoughts, &user.id, page).await?;
    Ok(Json(serde_json::json!({
        "total": result.total,
        "page": result.page,
        "per_page": result.per_page,
        "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
    })))
}
  • Fix tag_thoughts_handler — same:
pub async fn tag_thoughts_handler(
    State(s): State<AppState>,
    Path(tag_name): Path<String>,
    Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let page = PageParams { page: q.page(), per_page: q.per_page() };
    let result = get_by_tag(&*s.tags, &tag_name, page).await?;
    Ok(Json(serde_json::json!({
        "tag": tag_name,
        "total": result.total,
        "page": result.page,
        "per_page": result.per_page,
        "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
    })))
}

NOTE: get_by_tag returns Paginated<Thought>, not Paginated<FeedEntry> — it won't have author or counts. Check the use case signature. If it returns Paginated<Thought>, map manually keeping available fields only (id, content, visibility, dates). If it returns Paginated<FeedEntry>, use to_thought_response.

  • Run: cargo check -p presentation — Expected: no errors.

  • Commit:

git add crates/presentation/src/handlers/feed.rs
git commit -m "fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs"

Task 2: Wire follower/following REST routes

Files:

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

get_followers_handler and get_following_handler already exist in feed.rs (lines 7580). The AP routes own /users/{username}/followers and /users/{username}/following. Wire the REST handlers at non-conflicting paths:

  • Add two routes to api_routes in routes.rs, in the users section (before /thoughts):
.route("/users/{username}/follower-list", get(feed::get_followers_handler))
.route("/users/{username}/following-list", get(feed::get_following_handler))
  • Run: cargo check -p presentation — Expected: no errors.

  • Commit:

git add crates/presentation/src/routes.rs
git commit -m "feat(presentation): wire GET /users/{username}/follower-list and /following-list"

Task 3: User listing + count

Files:

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

  • Modify: crates/adapters/postgres/src/user.rs

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

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

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

  • Add count() to UserRepository in crates/domain/src/ports.rs:

async fn count(&self) -> Result<i64, DomainError>;
  • Implement count() in postgres — find impl UserRepository for PgUserRepository in crates/adapters/postgres/src/user.rs and add:
async fn count(&self) -> Result<i64, DomainError> {
    let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
        .fetch_one(&self.pool)
        .await
        .map_err(|e| DomainError::Internal(e.to_string()))?;
    Ok(row)
}
  • Implement count() in TestStore in crates/domain/src/testing.rs:
async fn count(&self) -> Result<i64, DomainError> {
    Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64)
}
  • Add handlers to crates/presentation/src/handlers/users.rs:
use domain::models::feed::UserSummary;

#[utoipa::path(
    get, path = "/users",
    params(
        ("q" = Option<String>, Query, description = "Search query"),
        PaginationQuery,
    ),
    responses((status = 200, description = "User list"))
)]
pub async fn get_users(
    State(s): State<AppState>,
    Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
    use domain::models::feed::PageParams;
    let page = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1u64);
    let per_page = params.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(20u64);
    let page_params = PageParams { page, per_page };

    if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
        let result = s.search.search_users(q, &page_params).await?;
        let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect();
        return Ok(Json(serde_json::json!({ "items": users, "total": result.total, "page": result.page, "per_page": result.per_page })));
    }

    let all = s.users.list_with_stats().await?;
    let total = all.len() as i64;
    let start = ((page - 1) * per_page) as usize;
    let items: Vec<_> = all.into_iter().skip(start).take(per_page as usize)
        .map(|u| serde_json::json!({
            "id": u.id.as_uuid(),
            "username": u.username,
            "display_name": u.display_name,
            "avatar_url": u.avatar_url,
            "bio": u.bio,
            "thought_count": u.thought_count,
            "follower_count": u.follower_count,
            "following_count": u.following_count,
        }))
        .collect();
    Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page })))
}

#[utoipa::path(
    get, path = "/users/count",
    responses((status = 200, description = "Local user count"))
)]
pub async fn get_user_count(
    State(s): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let count = s.users.count().await?;
    Ok(Json(serde_json::json!({ "count": count })))
}

Note: get_users needs use api_types::requests::PaginationQuery; added to imports if not already there. Check the file's existing imports.

  • Add routes to routes.rs — add BEFORE /users/me (static paths must come before parameterised):
.route("/users", get(users::get_users))
.route("/users/count", get(users::get_user_count))
  • Run: cargo check --workspace — Expected: no errors.

  • Commit:

git add crates/domain/src/ports.rs \
        crates/adapters/postgres/src/user.rs \
        crates/domain/src/testing.rs \
        crates/presentation/src/handlers/users.rs \
        crates/presentation/src/routes.rs
git commit -m "feat: GET /users (search/list) and GET /users/count"

Files:

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

  • Modify: crates/adapters/postgres/src/tag.rs

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

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

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

  • Add popular_tags() to TagRepository in crates/domain/src/ports.rs:

/// Returns (tag_name, thought_count) pairs, most-used first.
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError>;
  • Implement popular_tags() in postgres — find impl TagRepository for PgTagRepository in crates/adapters/postgres/src/tag.rs and add:
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
    let rows = sqlx::query_as::<_, (String, i64)>(
        "SELECT t.name, COUNT(tt.thought_id) AS thought_count
         FROM tags t
         JOIN thought_tags tt ON t.id = tt.tag_id
         GROUP BY t.id, t.name
         ORDER BY thought_count DESC
         LIMIT $1"
    )
    .bind(limit as i64)
    .fetch_all(&self.pool)
    .await
    .map_err(|e| DomainError::Internal(e.to_string()))?;
    Ok(rows)
}
  • Implement popular_tags() in TestStore in crates/domain/src/testing.rs:
async fn popular_tags(&self, _limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
    Ok(vec![])
}
  • Add get_popular_tags handler to crates/presentation/src/handlers/feed.rs:
pub async fn get_popular_tags(
    State(s): State<AppState>,
    Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20);
    let tags = s.tags.popular_tags(limit.min(100)).await?;
    Ok(Json(serde_json::json!({
        "tags": tags.iter().map(|(name, count)| serde_json::json!({
            "name": name,
            "thought_count": count,
        })).collect::<Vec<_>>()
    })))
}
  • Wire GET /tags/popular in routes.rs — add BEFORE /tags/{name} (otherwise popular is captured as the {name} param):
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))

The existing .route("/tags/{name}", ...) line can stay — just add the popular route immediately before it.

  • Run: cargo check --workspace — Expected: no errors.

  • Run unit tests: cargo test --workspace --exclude postgres --exclude postgres-federation --exclude postgres-search — Expected: all pass.

  • Commit:

git add crates/domain/src/ports.rs \
        crates/adapters/postgres/src/tag.rs \
        crates/domain/src/testing.rs \
        crates/presentation/src/handlers/feed.rs \
        crates/presentation/src/routes.rs
git commit -m "feat: GET /tags/popular — top tags by usage count"

Task 5: Config — HOST, CORS_ORIGINS, RATE_LIMIT

Files:

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

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

  • Modify: crates/bootstrap/Cargo.toml

  • Modify: .env.example

  • Add tower-governor to crates/bootstrap/Cargo.toml:

tower-governor = "0.6"
  • Add three fields to Config in crates/bootstrap/src/config.rs:
pub struct Config {
    pub database_url: String,
    pub jwt_secret: String,
    pub base_url: String,
    pub nats_url: Option<String>,
    pub port: u16,
    pub host: String,
    pub allow_registration: bool,
    pub debug: bool,
    /// Comma-separated allowed origins, or "*" for permissive. Default: "*".
    pub cors_origins: String,
    /// Max requests per minute per IP. None = disabled.
    pub rate_limit: Option<u32>,
}

In from_env() add:

host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()),
rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()),
  • Update crates/bootstrap/src/main.rs:
mod config;
mod factory;

use std::sync::Arc;
use http::HeaderValue;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() {
    let cfg = config::Config::from_env();

    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let infra = factory::build(&cfg).await;

    // CORS
    let cors = if cfg.cors_origins.trim() == "*" {
        CorsLayer::permissive()
    } else {
        let origins: Vec<HeaderValue> = cfg.cors_origins
            .split(',')
            .map(|o| o.trim())
            .filter_map(|o| o.parse().ok())
            .collect();
        CorsLayer::new()
            .allow_origin(AllowOrigin::list(origins))
            .allow_methods(tower_http::cors::Any)
            .allow_headers(tower_http::cors::Any)
    };

    let app = presentation::routes::router(&infra.fed_config)
        .with_state(infra.state)
        .layer(cors);

    // Rate limiting (optional)
    let app = if let Some(rate_limit) = cfg.rate_limit {
        use tower_governor::{GovernorLayer, GovernorConfigBuilder};
        let governor_config = Arc::new(
            GovernorConfigBuilder::default()
                .per_millisecond(60_000 / rate_limit as u64)
                .burst_size(rate_limit)
                .use_headers()
                .finish()
                .expect("valid rate limit config"),
        );
        let limiter = governor_config.limiter().clone();
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
            loop {
                interval.tick().await;
                limiter.retain_recent();
            }
        });
        app.layer(GovernorLayer { config: governor_config })
    } else {
        app
    };

    let addr = format!("{}:{}", cfg.host, cfg.port);
    tracing::info!("Listening on {addr}");
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Note: tower-governor's GovernorLayer API may differ slightly — check the actual 0.6.x docs and adjust. The GovernorConfigBuilder might use .per_second() instead of .per_millisecond(). Verify and use whichever method produces the desired requests-per-minute rate.

Note 2: Axum Router::layer returns the same type when adding a standard layer. GovernorLayer returns a different type. If the type system complains, wrap the app in tower::ServiceBuilder or use .layer(tower::ServiceBuilder::new().layer(GovernorLayer { ... }).into_inner()).

  • Update .env.example — add the three new vars:
# Optional
HOST=0.0.0.0
PORT=3000
ALLOW_REGISTRATION=true
RUST_ENV=development

# CORS — comma-separated origins, or * for permissive (default: *)
CORS_ORIGINS=*
# CORS_ORIGINS=https://your-nextjs-app.example.com

# Rate limiting — max requests per minute per IP (disabled by default)
# RATE_LIMIT=60
  • Run: cargo check -p bootstrap — Expected: no errors (fix tower-governor API if needed).

  • Commit:

git add crates/bootstrap/ .env.example
git commit -m "feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting"

Self-Review

Spec coverage:

  • home_feed / public_feed return full ThoughtResponse (Task 1)
  • user_thoughts_handler / tag_thoughts_handler use to_thought_response (Task 1)
  • GET /users/{username}/follower-list and /following-list wired (Task 2)
  • GET /users (search + list) + GET /users/count (Task 3)
  • UserRepository::count() in port + postgres + TestStore (Task 3)
  • GET /tags/popular wired before /tags/{name} (Task 4)
  • TagRepository::popular_tags() in port + postgres + TestStore (Task 4)
  • HOST, CORS_ORIGINS, RATE_LIMIT in Config (Task 5)
  • CORS layer uses configured origins (Task 5)
  • Rate limiting via tower-governor, disabled by default (Task 5)

Placeholder scan: None.

Type consistency:

  • to_thought_response maps FeedEntryThoughtResponse — both types confirmed in source
  • tag_thoughts_handler uses get_by_tag which returns Paginated<Thought> — verify whether it returns Thought or FeedEntry and adjust the mapping accordingly
  • popular_tags() returns Vec<(String, i64)> — matches the SQL query's two columns
  • GovernorLayer API — implementer must verify against installed tower-governor version