# Middleware Axum Server 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:** Give the previously-orphaned `zesdex-middleware` crate (`SessionAuthLayer`, `default_cors_layer`, `RateLimitLayer`) a genuine integration point by adding an optional HTTP transport for the existing daemon, alongside (not replacing) the current Unix-socket transport. **Important scope note — read before implementing:** unlike the OAuth/session/CMS wiring plans, there is **no existing HTTP server to fix or complete** — research confirmed zero axum usage anywhere in `zesdex-backend` and no design doc describing what one should do. This plan is therefore new-feature work, deliberately scoped as narrowly as possible: it exposes the *exact same* `ClientRequest`/`DaemonFrame` protocol the Unix-socket daemon already speaks, over HTTP, gated by the three middlewares. It does **not** invent a new REST API surface (no per-resource endpoints for settings/sessions/memory) — that would be scope creep beyond "give this crate a caller." **Architecture:** Extract the daemon's per-request handling logic (`handle_daemon_client`'s match-on-`ClientRequest` body plus `send_daemon_update`) into transport-agnostic functions shared by both the existing Unix-socket loop and a new axum route. The state-owning thread gains an `mpsc` channel; the axum handler sends `(ClientRequest, oneshot::Sender>)` and awaits the reply. `--http-port ` is a new opt-in CLI flag on `--daemon` — when absent, behavior is byte-for-byte identical to today (Unix socket only). **Tech Stack:** Rust, `axum`, `tokio` (already workspace deps), `zesdex-middleware`. ## Global Constraints - The Unix-socket transport's behavior must be provably unchanged — the refactor in Task 1 extracts logic without altering it, verified by the existing (or newly added, if none exist) daemon tests passing identically before and after. - No new `#[allow(...)]` attributes. - Tests are inline `#[cfg(test)] mod tests`. - Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit. - The HTTP transport is opt-in (`--http-port`) and OFF by default — it must not change any existing invocation's behavior. --- ### Task 1: Extract transport-agnostic request handling from `handle_daemon_client` **Files:** - Modify: `crates/zesdex-backend/src/main.rs` (functions `handle_daemon_client` ~line 336, `send_daemon_update` ~line 207) **Interfaces:** - Produces: `fn build_state_update_frame(state: &AppStateRest) -> ipc::protocol::DaemonFrame` (pure builder, extracted from `send_daemon_update`) and `fn process_client_request(state: &mut AppStateRest, req: ipc::protocol::ClientRequest) -> (bool, Vec)` (pure state-mutation + frame-collection, extracted from `handle_daemon_client`'s match body) — both consumed by Task 3's axum handler and Task 2's refactored Unix-socket loop. - [ ] **Step 1: Extract `build_state_update_frame`** In `crates/zesdex-backend/src/main.rs`, split `send_daemon_update` (current body at line ~207-238) into a pure builder plus a thin I/O wrapper: ```rust /// Flatten the daemon's `AppStateRest` into a `StatePayload` wrapped in a /// `DaemonFrame::StateUpdate` — the pure, transport-agnostic half of what /// was previously `send_daemon_update`. /// /// Why: the client never shares memory with the daemon, so every action /// on the daemon side is followed by a full state push rather than a diff. fn build_state_update_frame(state: &app::state::rest::AppStateRest) -> ipc::protocol::DaemonFrame { use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload}; let messages: Vec = state.transcript_cache.messages.iter().map(|m| { MessageEntry { role: format!("{:?}", m.role), content: m.content.clone(), timestamp: m.timestamp, } }).collect(); let toasts: Vec = state.misc.toasts.iter().map(|t| { ToastEntry { kind: format!("{:?}", t.kind), message: t.message.clone(), created_at: t.created_at, lifetime_ms: t.lifetime_ms, } }).collect(); let overlay = if state.misc.overlay.is_active() { Some(format!("{:?}", state.misc.overlay)) } else { None }; // Keep every remaining `StatePayload` field exactly as the original // `send_daemon_update` built it (input buffer/cursor, etc.) — copy the // rest of the struct-literal body unchanged from the pre-refactor code. DaemonFrame::StateUpdate(Box::new(StatePayload { session_id: state.session_id.clone(), messages, toasts, overlay, // ...(remaining fields copied verbatim from the original function) })) } /// Send a `DaemonFrame::StateUpdate` to an attached Unix-socket client. fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> { conn.send(&build_state_update_frame(state)) } ``` (Read the full original `send_daemon_update` body first — `sed`/`grep -n -A 45 "fn send_daemon_update" crates/zesdex-backend/src/main.rs` — and carry over every `StatePayload` field exactly; the excerpt above only shows the fields already visible in this plan's earlier research, do not drop any field the original builds.) - [ ] **Step 2: Extract `process_client_request`** Replace `handle_daemon_client`'s inner `match req { ... }` block with a new standalone function that returns frames instead of writing to a `Connection`: ```rust /// Apply one `ClientRequest` to `state` and collect the `DaemonFrame`(s) it /// produces — the pure, transport-agnostic half of what was previously /// inlined in `handle_daemon_client`'s read loop. /// /// Return: `(keep_running, frames)` — `keep_running` is `false` only for /// `ClientRequest::Close`; `frames` always ends with a `StateUpdate` frame, /// preceded by a `ClipboardCopy` frame if a copy was pending. fn process_client_request( state: &mut app::state::rest::AppStateRest, req: ipc::protocol::ClientRequest, ) -> (bool, Vec) { use app::runtime::actions::{Action, apply_action}; use ipc::protocol::ClientRequest; let mut running = true; match req { ClientRequest::Tick => { apply_action(state, Action::Tick); } ClientRequest::KeyPress { key, ctrl, alt, shift } => { let mut modifiers = crossterm::event::KeyModifiers::NONE; if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; } if alt { modifiers |= crossterm::event::KeyModifiers::ALT; } if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; } let key_event = crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers); let actions = controller::input::handle_key(key_event, state); for action in actions { apply_action(state, action); } apply_action(state, Action::Tick); } ClientRequest::Submit(text) => { state.input.buffer = text; let enter_event = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE); let actions = controller::input::handle_key(enter_event, state); for action in actions { apply_action(state, action); } apply_action(state, Action::Tick); } ClientRequest::Paste(text) => { state.input.buffer.insert_str(state.input.cursor, &text); state.input.cursor += text.len(); state.dirty = true; apply_action(state, Action::Tick); } ClientRequest::Resize(w, h) => { apply_action(state, Action::Resize(w, h)); apply_action(state, Action::Tick); } ClientRequest::ScrollUp => { apply_action(state, Action::ScrollUp); apply_action(state, Action::Tick); } ClientRequest::ScrollDown => { apply_action(state, Action::ScrollDown); apply_action(state, Action::Tick); } ClientRequest::Close => { running = false; } } let mut frames = Vec::new(); if let Some(text) = state.misc.pending_clipboard_copy.take() { frames.push(ipc::protocol::DaemonFrame::ClipboardCopy(text)); } frames.push(build_state_update_frame(state)); (running, frames) } ``` (Every match arm's body is copied verbatim from the pre-refactor `handle_daemon_client` — no logic changes, only relocation.) - [ ] **Step 3: Rewrite `handle_daemon_client` as a thin wrapper** ```rust fn handle_daemon_client( mut conn: ipc::conn::Connection, state: &mut app::state::rest::AppStateRest, ) -> Result<()> { use ipc::protocol::ClientRequest; loop { match conn.receive::()? { Some(req) => { let (running, frames) = process_client_request(state, req); for frame in frames { conn.send(&frame)?; } if !running { break; } } None => break, } } Ok(()) } ``` (Note: the original sent `ClipboardCopy` then a `StateUpdate` as two separate `conn.send` calls per request — the `for frame in frames` loop preserves that exact ordering since `process_client_request` pushes them in the same order.) - [ ] **Step 4: Build and test** Run: `cargo build -p zesdex-backend && cargo test -p zesdex-backend` Expected: no errors, all existing tests pass. - [ ] **Step 5: Manual regression check on the Unix-socket path** Run the daemon + attach flow manually (`cargo run -p zesdex-backend -- --daemon` in one terminal, `cargo run -p zesdex-backend -- --attach ` in another) and confirm keypresses, submit, resize, scroll, and clean close all behave exactly as before this refactor. - [ ] **Step 6: Commit** ```bash git add crates/zesdex-backend/src/main.rs git commit -m "refactor(backend): ekstrak process_client_request/build_state_update_frame agar transport-agnostic" ``` --- ### Task 2: Add an `mpsc`-bridged worker so the state owner can serve two transports **Files:** - Modify: `crates/zesdex-backend/src/main.rs` (`run_daemon`, ~line 427) **Interfaces:** - Produces: `run_daemon` spawns the existing Unix-socket accept loop on the calling thread as today, but if `--http-port` is set (Task 4), a second axum server (Task 3) sends requests into the same state via a shared `std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender>)>` that the daemon's main loop polls alongside the Unix-socket `accept()`. - [ ] **Step 1: Add a request channel to the daemon loop** Read the current `run_daemon` in full first: `grep -n -A 60 "fn run_daemon" crates/zesdex-backend/src/main.rs` Introduce, near the top of `run_daemon` (after `state` is constructed, before the accept loop): ```rust // Bridge channel: lets an (optional) HTTP transport submit // `ClientRequest`s into this thread's owned `AppStateRest`, exactly as // the Unix-socket accept loop does. `bridge_rx` is polled with a // short timeout alongside `server.accept()` so both transports can // make progress on the single thread that owns `state`. let (bridge_tx, bridge_rx) = std::sync::mpsc::channel::<( ipc::protocol::ClientRequest, std::sync::mpsc::Sender>, )>(); ``` - [ ] **Step 2: Poll the bridge channel in the accept loop** Locate the existing `loop { match server.accept() { ... } }` (or equivalent) in `run_daemon`. Since `UnixListener::accept()` blocks, switch it to non-blocking with a short poll interval so the bridge channel also gets serviced: ```rust server.set_nonblocking(true)?; // confirm `IpcServer` exposes this — if not, add a thin `set_nonblocking` passthrough to `zesdex-ipc`'s `IpcServer` in this same task loop { // Drain any pending HTTP-bridged requests first. while let Ok((req, reply_tx)) = bridge_rx.try_recv() { let (_running, frames) = process_client_request(&mut state, req); let _ = reply_tx.send(frames); } match server.accept() { Ok(conn) => { handle_daemon_client(conn, &mut state)?; } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(std::time::Duration::from_millis(20)); } Err(e) => { eprintln!("daemon: accept error: {e}"); } } } ``` (If `IpcServer` doesn't currently expose `set_nonblocking`, add it to `crates/zesdex-ipc/src/server.rs` as a one-line passthrough to the underlying `UnixListener::set_nonblocking`, with a doc comment explaining why: enables polling the HTTP bridge channel on the same thread without blocking indefinitely on Unix-socket `accept()`.) - [ ] **Step 3: Thread `bridge_tx` out to Task 3** Have `run_daemon` pass a clone of `bridge_tx` to the HTTP-server-spawning code added in Task 4 (only reached when `--http-port` is set). - [ ] **Step 4: Build** Run: `cargo check -p zesdex-backend` Expected: no errors (Task 4 hasn't added the HTTP server yet, so `bridge_tx` may show an "unused" warning until then — acceptable transiently within this plan's own task sequence, but must be resolved by the time Task 4 finishes; do not leave an `#[allow(dead_code)]` on it in the interim). - [ ] **Step 5: Commit** ```bash git add crates/zesdex-backend/src/main.rs crates/zesdex-ipc/src/server.rs git commit -m "feat(backend): tambahkan channel jembatan mpsc di run_daemon untuk transport HTTP opsional" ``` --- ### Task 3: Add the axum HTTP bridge endpoint using `zesdex-middleware` **Files:** - Create: `crates/zesdex-backend/src/ipc_http.rs` - Modify: `crates/zesdex-backend/src/main.rs` (module declaration + call site) - Modify: `crates/zesdex-backend/Cargo.toml` (confirm `axum`/`tokio` already present — they are, per workspace deps; no change needed, just verify with `grep -E "^axum|^tokio" crates/zesdex-backend/Cargo.toml`) **Interfaces:** - Consumes: `zesdex_middleware::auth::{SessionAuthLayer, SessionIdentity}`, `zesdex_middleware::cors::default_cors_layer`, `zesdex_middleware::rate_limit::{RateLimiter, RateLimitLayer}` (with `trust_proxy_headers: false` per the `2026-07-16-security-quickfixes.md` plan's Task 2), the `bridge_tx` sender from Task 2. - Produces: `pub async fn serve_http_bridge(port: u16, store: zesdex_entities::seaorm::common::store::Store, bridge_tx: std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender>)>) -> anyhow::Result<()>` — spawned as a tokio task by `run_daemon` when `--http-port` is set. - [ ] **Step 1: Write the route handler** Create `crates/zesdex-backend/src/ipc_http.rs`: ```rust //! Optional HTTP transport for the daemon, bridging to the same //! `ClientRequest`/`DaemonFrame` protocol the Unix-socket transport uses. //! //! Exists solely to give `zesdex-middleware`'s `SessionAuthLayer`, //! `default_cors_layer`, and `RateLimitLayer` a real caller — it //! deliberately does NOT introduce a new REST API surface; the one route //! below is a thin bridge onto the pre-existing IPC protocol. use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Json}; use axum::routing::post; use axum::Router; use ipc::protocol::{ClientRequest, DaemonFrame}; type BridgeSender = std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender>)>; #[derive(Clone)] struct HttpBridgeState { bridge_tx: std::sync::Arc>, } /// Handle one bridged `ClientRequest`, blocking (on a blocking-safe tokio /// task) until the daemon's state-owning thread replies. /// /// Flow: build a one-shot `std::sync::mpsc` reply channel → send /// `(req, reply_tx)` into the daemon's bridge channel → block on /// `reply_rx.recv()` via `tokio::task::spawn_blocking` (since the daemon /// thread's reply is synchronous, not a future) → return the frames as /// JSON. /// /// Return: `200` with the frame list on success, `500` if the daemon /// thread is gone (channel send/receive failed) or the bridge send failed. async fn handle_request( State(state): State, Json(req): Json, ) -> impl IntoResponse { let (reply_tx, reply_rx) = std::sync::mpsc::channel(); let send_result = state .bridge_tx .lock() .map_err(|_| ()) .and_then(|tx| tx.send((req, reply_tx)).map_err(|_| ())); if send_result.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(Vec::::new())); } let frames = tokio::task::spawn_blocking(move || reply_rx.recv().unwrap_or_default()) .await .unwrap_or_default(); (StatusCode::OK, Json(frames)) } /// Serve the HTTP bridge on `127.0.0.1:`, gated by session auth, /// CORS, and rate limiting from `zesdex-middleware`. /// /// Why 127.0.0.1 only: this bridge is meant for local attach clients that /// prefer HTTP over a Unix socket (e.g. a browser-based frontend on the /// same machine), not a remote API — it is never exposed beyond loopback. /// /// Return: `Err` if the port can't be bound; otherwise runs until the /// process exits (mirrors the Unix-socket daemon's lifetime). pub async fn serve_http_bridge( port: u16, store: zesdex_entities::seaorm::common::store::Store, bridge_tx: BridgeSender, ) -> anyhow::Result<()> { let http_state = HttpBridgeState { bridge_tx: std::sync::Arc::new(std::sync::Mutex::new(bridge_tx)), }; let rate_limiter = zesdex_middleware::rate_limit::RateLimiter::new(/* existing constructor args, e.g. window/limit — read crates/zesdex-middleware/src/rate_limit.rs's `RateLimiter::new` signature first */); let app = Router::new() .route("/ipc/request", post(handle_request)) .layer(zesdex_middleware::auth::SessionAuthLayer::new(store)) .layer(zesdex_middleware::cors::default_cors_layer()) .layer(zesdex_middleware::rate_limit::RateLimitLayer::new(rate_limiter)) .with_state(http_state); let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!("[http-bridge] listening on {addr}"); axum::serve(listener, app.into_make_service_with_connect_info::()).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn serve_http_bridge_rejects_requests_without_session_header() { let store = zesdex_entities::seaorm::common::store::Store::new(); let (tx, _rx) = std::sync::mpsc::channel(); // Bind on port 0 equivalent isn't directly expressible via this // function's fixed-port signature — for this test, spawn the // server on an ephemeral high port and hit it with `reqwest`, // asserting a 401 when `X-Session-Id` is absent. Pick a // collision-unlikely test port derived from the process id to // avoid flaky parallel-test port clashes: let port = 20000 + (std::process::id() % 10000) as u16; let server = tokio::spawn(serve_http_bridge(port, store, tx)); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let resp = reqwest::Client::new() .post(format!("http://127.0.0.1:{port}/ipc/request")) .json(&ClientRequest::Tick) .send() .await .expect("request should reach the server"); assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); server.abort(); } } ``` (The `RateLimiter::new` call needs its actual constructor arguments — read `crates/zesdex-middleware/src/rate_limit.rs` first to fill these in precisely; after applying `2026-07-16-security-quickfixes.md`'s Task 2, prefer `RateLimiter::new(...)` — the safe, non-proxy-trusting constructor — over `with_proxy_trust`, since this bridge sits directly on loopback with no fronting proxy.) - [ ] **Step 2: Register the module** In `crates/zesdex-backend/src/main.rs`, add near the other `mod`/`use` declarations: ```rust mod ipc_http; ``` - [ ] **Step 3: Run the test** Run: `cargo test -p zesdex-backend serve_http_bridge_rejects -- --nocapture` Expected: pass (needs `SessionAuthLayer` to actually reject unauthenticated requests — if the test fails because `SessionAuthLayer`'s validation logic doesn't match this expectation, read `crates/zesdex-middleware/src/auth.rs`'s `validate_session`/`SessionAuthMiddleware::call` in full and adjust the test to match its actual documented rejection behavior rather than changing the middleware itself, since that's pre-existing, previously-audited code out of this plan's scope). - [ ] **Step 4: Commit** ```bash git add crates/zesdex-backend/src/ipc_http.rs crates/zesdex-backend/src/main.rs git commit -m "feat(backend): tambahkan HTTP bridge axum untuk IPC, memakai zesdex-middleware" ``` --- ### Task 4: Add the `--http-port` CLI flag **Files:** - Modify: `crates/zesdex-backend/src/main.rs` (argument parsing near the `--daemon`/`--attach` flags, and the end of `run_daemon` where the bridge is spawned) **Interfaces:** none new — wires Task 2's `bridge_tx` and Task 3's `serve_http_bridge` together, gated by the flag. - [ ] **Step 1: Add flag parsing** Read the existing flag-parsing code first: `grep -n -B2 -A10 "\-\-daemon\|\-\-attach" crates/zesdex-backend/src/main.rs | head -40` Add a `--http-port ` flag using the same parsing style already present (whatever library/manual parsing the existing flags use), defaulting to `None` (HTTP transport disabled) when absent. - [ ] **Step 2: Spawn the HTTP bridge conditionally in `run_daemon`** After Task 2's `bridge_tx`/`bridge_rx` setup, add: ```rust if let Some(port) = http_port { let store_for_http = zesdex_entities::seaorm::common::store::Store::new(); let bridge_tx_for_http = bridge_tx.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime for HTTP bridge"); if let Err(e) = rt.block_on(ipc_http::serve_http_bridge(port, store_for_http, bridge_tx_for_http)) { tracing::error!("[http-bridge] server error: {e}"); } }); } ``` - [ ] **Step 3: Build and test** Run: `cargo build --workspace && cargo test --workspace` Expected: no errors, all pass. - [ ] **Step 4: Manual smoke test — HTTP transport off by default** Run: `cargo run -p zesdex-backend -- --daemon` (no `--http-port`). Confirm the daemon starts and the Unix-socket path works exactly as before (attach a client, verify interaction). - [ ] **Step 5: Manual smoke test — HTTP transport enabled** Run: `cargo run -p zesdex-backend -- --daemon --http-port 18080`. From another terminal, `curl -X POST http://127.0.0.1:18080/ipc/request -H 'Content-Type: application/json' -H 'X-Session-Id: ' -d '"Tick"'` and confirm a `200` with a JSON frame list; retry without the `X-Session-Id` header and confirm `401`. - [ ] **Step 6: Commit** ```bash git add crates/zesdex-backend/src/main.rs git commit -m "feat(backend): tambahkan flag --http-port opsional untuk daemon HTTP bridge" ``` --- ### Task 5: Run the full workspace verification - [ ] **Step 1: Full build** Run: `cargo build --workspace` - [ ] **Step 2: Full test suite** Run: `cargo test --workspace` - [ ] **Step 3: Full clippy** Run: `cargo clippy --workspace --all-targets -- -D warnings` - [ ] **Step 4: Confirm `zesdex-middleware` is no longer orphaned** Run: `grep -rln "zesdex_middleware::" crates/zesdex-backend/src` Expected: `crates/zesdex-backend/src/ipc_http.rs` (this plan's new file). - [ ] **Step 5: Commit (if any cleanup was needed)** ```bash git add -A git commit -m "chore: verifikasi akhir wiring zesdex-middleware ke daemon HTTP bridge" ```