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 --workspaceandcargo clippy --workspace --all-targets -- -D warningsbefore 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(functionshandle_daemon_client~line 336,send_daemon_update~line 207)
Interfaces:
-
Produces:
fn build_state_update_frame(state: &AppStateRest) -> ipc::protocol::DaemonFrame(pure builder, extracted fromsend_daemon_update) andfn process_client_request(state: &mut AppStateRest, req: ipc::protocol::ClientRequest) -> (bool, Vec<ipc::protocol::DaemonFrame>)(pure state-mutation + frame-collection, extracted fromhandle_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_clientas 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_daemonspawns the existing Unix-socket accept loop on the calling thread as today, but if--http-portis set (Task 4), a second axum server (Task 3) sends requests into the same state via a sharedstd::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>that the daemon's main loop polls alongside the Unix-socketaccept(). -
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_txout 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(confirmaxum/tokioalready present — they are, per workspace deps; no change needed, just verify withgrep -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}(withtrust_proxy_headers: falseper the2026-07-16-security-quickfixes.mdplan's Task 2), thebridge_txsender 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 byrun_daemonwhen--http-portis 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/--attachflags, and the end ofrun_daemonwhere 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-middlewareis 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"