Files
zesdex/docs/superpowers/plans/2026-07-16-middleware-axum-server.md
T

24 KiB

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<Vec<DaemonFrame>>) and awaits the reply. --http-port <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<ipc::protocol::DaemonFrame>) (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:

/// 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<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
        MessageEntry {
            role: format!("{:?}", m.role),
            content: m.content.clone(),
            timestamp: m.timestamp,
        }
    }).collect();

    let toasts: Vec<ToastEntry> = 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:

/// 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<ipc::protocol::DaemonFrame>) {
    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
fn handle_daemon_client(
    mut conn: ipc::conn::Connection,
    state: &mut app::state::rest::AppStateRest,
) -> Result<()> {
    use ipc::protocol::ClientRequest;

    loop {
        match conn.receive::<ClientRequest>()? {
            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 <session-id> in another) and confirm keypresses, submit, resize, scroll, and clean close all behave exactly as before this refactor.

  • Step 6: Commit
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<Vec<DaemonFrame>>)> 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):

    // 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<Vec<ipc::protocol::DaemonFrame>>,
    )>();
  • 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:

    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
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<Vec<DaemonFrame>>)>) -> 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:

//! 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<Vec<DaemonFrame>>)>;

#[derive(Clone)]
struct HttpBridgeState {
    bridge_tx: std::sync::Arc<std::sync::Mutex<BridgeSender>>,
}

/// 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<HttpBridgeState>,
    Json(req): Json<ClientRequest>,
) -> 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::<DaemonFrame>::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:<port>`, 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::<std::net::SocketAddr>()).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:

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
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 <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:

    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: <a-real-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
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)
git add -A
git commit -m "chore: verifikasi akhir wiring zesdex-middleware ke daemon HTTP bridge"