chore: hapus implementasi OAuth/session lama yang sudah digantikan zesdex-iam

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
co-authored by Claude Sonnet 5
parent add6845edf
commit f51a32569f
13 changed files with 3700 additions and 770 deletions
@@ -0,0 +1,523 @@
# 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:
```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<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`:
```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<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**
```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::<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**
```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<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):
```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<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:
```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<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`:
```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<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:
```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 <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: <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**
```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"
```