diff --git a/docs/superpowers/plans/2026-05-14-bootstrap-factory.md b/docs/superpowers/plans/2026-05-14-bootstrap-factory.md new file mode 100644 index 0000000..f5e7132 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-bootstrap-factory.md @@ -0,0 +1,431 @@ +# Bootstrap Factory 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:** Extract the composition root out of `presentation` into a dedicated `bootstrap` crate with a `factory.rs` that builds all dependencies from config — so `presentation` becomes a pure HTTP library with no knowledge of concrete adapters. + +**Architecture:** `crates/bootstrap/` is a new binary crate. It contains `config.rs` (reads env vars), `factory.rs` (creates all concrete `Arc` adapters and returns `Infrastructure { state, fed_config }`), and a thin `main.rs`. `presentation` loses `[[bin]]`, `build_state`, and all concrete adapter imports — it only depends on `domain`, `application`, `api-types`, `axum`, `activitypub-base`, and UI/docs libs. + +**Tech Stack:** existing Rust workspace, sqlx, async-nats, all existing adapter crates + +--- + +## File Map + +``` +Create: crates/bootstrap/Cargo.toml ← binary crate, imports all concrete adapters +Create: crates/bootstrap/src/config.rs ← Config struct + from_env() +Create: crates/bootstrap/src/factory.rs ← build(config) → Infrastructure { state, fed_config } +Create: crates/bootstrap/src/main.rs ← thin: read config, call factory, serve + +Modify: Cargo.toml (root) ← add "crates/bootstrap" to workspace members +Modify: crates/presentation/Cargo.toml ← remove [[bin]], remove all concrete adapter deps +Modify: crates/presentation/src/lib.rs ← remove build_state + NoOpEventPublisher + imports +Modify: crates/presentation/src/state.rs ← remove fed_config field +Delete: crates/presentation/src/main.rs ← binary moves to bootstrap +``` + +**Key design decision:** `fed_config` is removed from `AppState`. `factory::build()` returns `Infrastructure { state, fed_config }` separately. `main.rs` passes them independently to `router(&infra.fed_config).with_state(infra.state)`. This makes `AppState` pure `Arc` with no infrastructure types. + +--- + +### Task 1: Create bootstrap crate + +**Files:** +- Create: `crates/bootstrap/Cargo.toml` +- Create: `crates/bootstrap/src/config.rs` +- Create: `crates/bootstrap/src/factory.rs` +- Create: `crates/bootstrap/src/main.rs` +- Modify: `Cargo.toml` (root) + +- [ ] **Add `"crates/bootstrap"` to `[workspace] members`** in root `Cargo.toml`. + +- [ ] **Create `crates/bootstrap/Cargo.toml`:** + +```toml +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "thoughts" +path = "src/main.rs" + +[dependencies] +presentation = { workspace = true } +domain = { workspace = true } +postgres = { workspace = true } +postgres-search = { workspace = true } +postgres-federation = { workspace = true } +activitypub = { workspace = true } +activitypub-base = { workspace = true } +nats = { workspace = true } +auth = { workspace = true } +sqlx = { workspace = true } +async-nats = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } +``` + +- [ ] **Create `crates/bootstrap/src/config.rs`:** + +```rust +/// All configuration read from environment variables at startup. +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub base_url: String, + pub nats_url: Option, + pub port: u16, + pub allow_registration: bool, + /// true when RUST_ENV != "production" — enables AP debug mode + pub debug: bool, +} + +impl Config { + pub fn from_env() -> Self { + dotenvy::dotenv().ok(); + Self { + database_url: std::env::var("DATABASE_URL") + .expect("DATABASE_URL is required"), + jwt_secret: std::env::var("JWT_SECRET") + .expect("JWT_SECRET is required"), + base_url: std::env::var("BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()), + nats_url: std::env::var("NATS_URL").ok(), + port: std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000), + allow_registration: std::env::var("ALLOW_REGISTRATION") + .map(|v| v == "true") + .unwrap_or(true), + debug: std::env::var("RUST_ENV") + .map(|v| v != "production") + .unwrap_or(true), + } + } +} +``` + +- [ ] **Create `crates/bootstrap/src/factory.rs`:** + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::PgPool; + +use activitypub::ThoughtsObjectHandler; +use activitypub_base::{ApFederationConfig, FederationData}; +use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; +use postgres::activitypub::PgActivityPubRepository; +use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; +use presentation::state::AppState; + +use crate::config::Config; + +/// Everything the binary needs to start serving: the axum state and the +/// federation config (used when building the router). +pub struct Infrastructure { + pub state: AppState, + pub fed_config: ApFederationConfig, +} + +// ── No-op publisher (fallback when NATS is unavailable) ────────────────────── + +struct NoOpEventPublisher; + +#[async_trait] +impl EventPublisher for NoOpEventPublisher { + async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +pub async fn build(cfg: &Config) -> Infrastructure { + // 1. Database connection + migrations + let pool = PgPool::connect(&cfg.database_url) + .await + .expect("Failed to connect to database"); + sqlx::migrate!("../adapters/postgres/migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + tracing::info!("Database connected and migrations applied"); + + // 2. Event publisher — real NATS or no-op fallback + let event_publisher: Arc = match &cfg.nats_url { + Some(url) => match async_nats::connect(url).await { + Ok(client) => { + tracing::info!("Connected to NATS at {url}"); + Arc::new(nats::NatsEventPublisher::new(client)) + } + Err(e) => { + tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher"); + Arc::new(NoOpEventPublisher) + } + }, + None => { + tracing::info!("NATS_URL not set — using no-op event publisher"); + Arc::new(NoOpEventPublisher) + } + }; + + // 3. ActivityPub federation + let fed_data = FederationData::new( + Arc::new(PostgresFederationRepository::new(pool.clone())), + Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), + Arc::new(ThoughtsObjectHandler::new( + Arc::new(PgActivityPubRepository::new(pool.clone())), + &cfg.base_url, + )), + cfg.base_url.clone(), + cfg.allow_registration, + "thoughts".to_string(), + None, // event_publisher wired separately via NATS + ); + let fed_config = ApFederationConfig::new(fed_data, cfg.debug) + .await + .expect("Failed to build federation config"); + + // 4. Application state — all concrete repos injected as Arc + let state = AppState { + users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), + thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), + likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), + boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), + follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), + blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), + tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), + api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), + top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), + notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), + remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), + feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), + search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())), + auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)), + hasher: Arc::new(auth::Argon2PasswordHasher), + events: event_publisher, + }; + + Infrastructure { state, fed_config } +} +``` + +- [ ] **Create `crates/bootstrap/src/main.rs`:** + +```rust +mod config; +mod factory; + +use tower_http::cors::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; + + let app = presentation::routes::router(&infra.fed_config) + .with_state(infra.state) + .layer(CorsLayer::permissive()); + + let addr = format!("0.0.0.0:{}", cfg.port); + tracing::info!("Listening on {addr}"); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} +``` + +- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors. + Note: `presentation` still has its old `[[bin]]` at this point — that's fine, both binaries exist temporarily. + +- [ ] **Smoke test from bootstrap:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ +JWT_SECRET=dev BASE_URL=http://localhost:3000 \ +RUST_LOG=info cargo run --bin thoughts +``` + +Open a second terminal: +```bash +curl -s -X POST http://localhost:3000/auth/register \ + -H 'content-type: application/json' \ + -d '{"username":"bootstraptest","email":"boot@test.com","password":"pw"}' | jq .token +``` +Expected: returns a JWT token. + +- [ ] **Commit:** +```bash +git add Cargo.toml crates/bootstrap/ +git commit -m "feat(bootstrap): composition root with Config + factory.rs" +``` + +--- + +### Task 2: Clean presentation — strip concrete deps, remove binary + +**Files:** +- Modify: `crates/presentation/Cargo.toml` +- Modify: `crates/presentation/src/lib.rs` +- Modify: `crates/presentation/src/state.rs` +- Delete: `crates/presentation/src/main.rs` + +- [ ] **Remove `[[bin]]` table and `src/main.rs` from `crates/presentation/Cargo.toml`:** + +Delete these lines entirely: +```toml +[[bin]] +name = "thoughts" +path = "src/main.rs" +``` + +- [ ] **Strip concrete adapter deps from `crates/presentation/Cargo.toml`:** + +Remove these lines: +```toml +postgres = { workspace = true } +postgres-search = { workspace = true } +postgres-federation = { workspace = true } +activitypub = { workspace = true } +nats = { workspace = true } +async-nats = { workspace = true } +sqlx = { workspace = true } +auth = { workspace = true } +dotenvy = { workspace = true } +tracing-subscriber = { workspace = true } +``` + +Keep these (they belong to the HTTP layer): +```toml +domain = { workspace = true } +application = { workspace = true } +api-types = { workspace = true } +axum = { workspace = true } +tower-http = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +sha2 = "0.10" +hex = "0.4" +activitypub-base = { workspace = true } +activitypub_federation = "0.7.0-beta.11" +url = { workspace = true } +utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } +utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } +``` + +- [ ] **Rewrite `crates/presentation/src/lib.rs`** — remove `build_state`, `NoOpEventPublisher`, and all concrete imports. The file becomes purely module declarations: + +```rust +pub mod errors; +pub mod extractors; +pub mod handlers; +pub mod openapi; +pub mod routes; +pub mod state; +``` + +- [ ] **Remove `fed_config` from `crates/presentation/src/state.rs`:** + +The `AppState` struct currently has `pub fed_config: ApFederationConfig`. Remove that field and its import. The struct becomes: + +```rust +use std::sync::Arc; +use domain::ports::*; + +#[derive(Clone)] +pub struct AppState { + pub users: Arc, + pub thoughts: Arc, + pub likes: Arc, + pub boosts: Arc, + pub follows: Arc, + pub blocks: Arc, + pub tags: Arc, + pub api_keys: Arc, + pub top_friends: Arc, + pub notifications: Arc, + pub remote_actors: Arc, + pub feed: Arc, + pub search: Arc, + pub auth: Arc, + pub hasher: Arc, + pub events: Arc, +} +``` + +- [ ] **Delete `crates/presentation/src/main.rs`:** + +```bash +rm crates/presentation/src/main.rs +``` + +- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. + +- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (bootstrap now owns the binary). + +- [ ] **Verify only bootstrap knows about postgres:** + +```bash +cargo tree -p presentation 2>/dev/null | grep -E "postgres|sqlx|nats|auth" | head -5 || echo "clean" +``` +Expected: `clean` — no concrete adapter deps in presentation. + +- [ ] **Run full test suite:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 +``` +Expected: all tests pass. + +- [ ] **Commit:** + +```bash +git add crates/presentation/Cargo.toml \ + crates/presentation/src/lib.rs \ + crates/presentation/src/state.rs +git rm crates/presentation/src/main.rs +git commit -m "refactor(presentation): pure HTTP library — remove build_state, concrete adapter deps, and binary" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `bootstrap/config.rs` reads all env vars into typed `Config` struct (Task 1) +- ✅ `bootstrap/factory.rs` builds all `Arc` adapters from `Config` (Task 1) +- ✅ `bootstrap/main.rs` is thin: read config → factory → serve (Task 1) +- ✅ `presentation` loses `[[bin]]`, `main.rs`, `build_state`, `NoOpEventPublisher` (Task 2) +- ✅ `presentation/Cargo.toml` no longer imports postgres, nats, auth, sqlx, etc. (Task 2) +- ✅ `AppState` has no `fed_config` field — pure `Arc` (Task 2) +- ✅ `cargo tree -p presentation | grep postgres` returns nothing (Task 2) + +**Placeholder scan:** None. + +**Type consistency:** +- `factory::build(cfg: &Config) -> Infrastructure` — matches `main.rs` call +- `Infrastructure { state: AppState, fed_config: ApFederationConfig }` — `state` matches `routes::router().with_state(state)`, `fed_config` matches `routes::router(&infra.fed_config)` +- `AppState` without `fed_config` — `factory.rs` constructs it correctly (no `fed_config:` field) +- `sqlx::migrate!("../adapters/postgres/migrations")` in `factory.rs` — path is relative to `CARGO_MANIFEST_DIR` of `bootstrap` crate (`crates/bootstrap/`), resolves to `crates/adapters/postgres/migrations` ✓ + +**Note on Dockerfile:** The existing `Dockerfile` references the `thoughts` binary. Since `bootstrap/Cargo.toml` uses `[[bin]] name = "thoughts"`, the binary name is unchanged — Dockerfile needs no update. + +**Note on worker:** `crates/worker/` is already a clean composition root — it wires its own deps in `main.rs`. No changes needed there.