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

563 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```rust
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:
```rust
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:
```rust
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`:
```rust
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:
```rust
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:**
```bash
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`):
```rust
.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:**
```bash
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`:
```rust
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:
```rust
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`:
```rust
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`:**
```rust
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):
```rust
.route("/users", get(users::get_users))
.route("/users/count", get(users::get_user_count))
```
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
- [ ] **Commit:**
```bash
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"
```
---
### Task 4: Popular tags
**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`:
```rust
/// 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:
```rust
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`:
```rust
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`:
```rust
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):
```rust
.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:**
```bash
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`:**
```toml
tower-governor = "0.6"
```
- [ ] **Add three fields to `Config` in `crates/bootstrap/src/config.rs`:**
```rust
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:
```rust
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`:**
```rust
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:
```env
# 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:**
```bash
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 `FeedEntry``ThoughtResponse` — 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