feat(token): add refresh token verification to TokenService

feat(bootstrap): create temporary settings and config files to prevent data loss

refactor(edit_log): switch from Vec to VecDeque for efficient memory management

fix(gateway): ensure store directories are created before starting the API server

refactor(bgbash): implement a global singleton for BashControl

feat(auth): enhance session authentication middleware to use SessionRepository

fix(edit_log_repo): update to use VecDeque for in-memory edit log storage

fix(memory_repo): add newline escaping for frontmatter fields

fix(session_lock_repo): improve error handling for lock file operations

fix(bash_tools): prevent path traversal in job_id argument

refactor(delete): enforce empty directory deletion in file system tools

fix(edit): optimize string replacement to only replace the first occurrence

fix(git_cred): improve credential management with piped input to git commands

feat(git_operator): add safety filter to block destructive git operations

fix(shell): register background jobs in Bash control

feat(spawn): add access tier specification for pipeline stages

refactor(hive_mind): run directives concurrently for improved performance

fix(auth): update refresh token verification in the refresh handler

fix(chat): optimize LLM client usage based on model matching

fix(conversations): enhance message deletion to target specific indices

feat(api): add JWT authentication middleware for all API routes

fix(state): implement refresh token verification in JwtTokenService

fix(daemon): improve usage tracking with saturating addition

fix(tui): handle compacted messages in the TUI state management
This commit is contained in:
asepharyana
2026-07-20 12:26:10 +07:00
parent 600ea041ef
commit a04651905f
26 changed files with 497 additions and 453 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ pub async fn refresh_handler(
// Verify the refresh token and extract the subject
let sub = state
.token_service
.verify_access_token(&req.refresh_token)
.verify_refresh_token(&req.refresh_token)
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
// Generate a fresh token pair
+12 -9
View File
@@ -109,18 +109,21 @@ pub async fn chat_completions_handler(
// Call the LLM provider (non-streaming)
//
// We create a temporary LlmClient with the overridden model so we
// don't mutate the shared state's client.
// When the requested model matches the shared client we reuse it to
// avoid allocating a new HTTP connection. Otherwise we create a
// temporary LlmClient with the requested model — the ownership
// lives on the stack via `temp_client`.
#[allow(unused_assignments)]
let mut temp_client: Option<zesdex_infrastructure::llm::LlmClient> = None;
let llm_client = if model == state.llm_client.model {
// Use the shared client directly
&state.llm_client
} else {
// Create a modified client for this request (only borrows, but
// we need to own it for the call — handled below)
//
// For simplicity, use the shared client with its model. A full
// implementation would override the model per request.
&state.llm_client
temp_client = Some(zesdex_infrastructure::llm::LlmClient::new(
state.llm_client.api_key.clone(),
model,
Some(state.llm_client.base_url.clone()),
));
temp_client.as_ref().unwrap()
};
let (response, usage) = llm_client
@@ -103,29 +103,44 @@ pub async fn add_message_handler(
))
}
/// DELETE /sessions/:id/conversations/:cid — delete a message from a conversation.
/// DELETE /sessions/:id/conversations/:cid — delete a single message by index.
///
/// Note: the `cid` parameter currently identifies the message index or the
/// entire conversation. For simplicity, this deletes the entire conversation
/// and creates a fresh one. A more sophisticated implementation would remove
/// a single message by index.
/// ## Flow
///
/// 1. Extract session ID and message index from the path.
/// 2. Load the conversation.
/// 3. Remove the message at `cid` (zero-based index).
/// 4. Persist the updated conversation.
///
/// ## Errors
///
/// - `404 Not Found` — conversation not found.
/// - `400 Bad Request` — session ID is empty or `cid` is not a valid integer.
/// - `404 Not Found` — conversation or message index not found.
#[tracing::instrument(skip(state))]
pub async fn delete_message_handler(
State(state): State<Arc<ApiState>>,
Path((id, _cid)): Path<(String, String)>,
Path((id, cid)): Path<(String, String)>,
) -> Result<axum::http::StatusCode, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
// Load conversation and clear all messages
// Parse the message index from the path
let index: usize = cid
.parse()
.map_err(|_| ApiError::BadRequest(format!("Invalid message index: {cid}")))?;
// Load conversation and remove the specific message by index
let mut conversation = state.conversation_service.load_conversation(&id)?;
conversation.messages.clear();
if index >= conversation.messages.len() {
return Err(ApiError::NotFound(format!(
"Message index {index} out of bounds (max: {})",
conversation.messages.len().saturating_sub(1)
)));
}
conversation.messages.remove(index);
state
.conversation_service
.save_conversation(&conversation)?;
+7
View File
@@ -56,10 +56,17 @@ pub fn build_router(state: ApiState) -> Router {
// CORS layer — permissive for local daemon / development use
let cors = CorsLayer::permissive();
// JWT auth middleware — validates Bearer tokens on all API routes.
// Health and auth endpoints (login/register/refresh) are also
// protected; adjust route ordering or add an allow-list inside the
// middleware if public access is needed.
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
// Combine all sub-routers under a versioned prefix
Router::new()
.nest("/api/v1", api_v1_router())
.layer(cors)
.layer(jwt_auth)
.with_state(shared_state)
}
+13
View File
@@ -120,6 +120,19 @@ impl TokenService for JwtTokenService {
let claims = verify_token(&self.secret, token)?;
Ok(claims.sub)
}
/// Verify a refresh token and return the subject claim.
///
/// Delegates to the same JWT verification function as access tokens;
/// the signature algorithm and secret are shared. Expiry validation
/// is handled by the JWT library against the `exp` claim embedded
/// in the token payload.
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::verify_token;
let claims = verify_token(&self.secret, token)?;
Ok(claims.sub)
}
}
// ---------------------------------------------------------------------------
+14 -10
View File
@@ -230,8 +230,8 @@ fn handle_tick(state: &mut AppStateRest) {
}
TurnEvent::Usage { tokens_in, tokens_out } => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
}
}
TurnEvent::ReviewUsage {
@@ -239,8 +239,8 @@ fn handle_tick(state: &mut AppStateRest) {
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
}
}
TurnEvent::Done => {
@@ -524,11 +524,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// ── Normal mode ───────────────────────────────────────────────────────
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.misc.overlay.is_active() {
vec![Action::QuitConfirm]
} else {
vec![Action::ForceQuit]
}
// Always show quit-confirm, regardless of overlay state.
vec![Action::QuitConfirm]
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay]
@@ -552,7 +549,14 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
}
KeyCode::Enter => {
if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
match state.misc.overlay {
Overlay::QuitConfirm => vec![Action::ForceQuit],
Overlay::ClearConfirm => vec![Action::SystemNote {
kind: "clear".to_string(),
message: "cleared".to_string(),
}],
_ => vec![Action::CloseOverlay],
}
} else if state.input.autocomplete_visible {
// Select the current autocomplete candidate
if !state.input.autocomplete_candidates.is_empty() {
+5
View File
@@ -151,6 +151,11 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg);
}
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
if let Some(ref mut rt) = state.session_runtime {
rt.messages = msgs;
}
}
zesdex_infrastructure::TurnEvent::Done => {
if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
*flag = false;
+5
View File
@@ -173,6 +173,11 @@ fn run_turn(
}
}
// Propagate accumulated messages back to session_runtime so the next
// turn starts with the full history (assistant replies + tool results).
// TurnEvent::Compacted already exists on the enum and is handled in
// action.rs to write back to state.session_runtime.messages.
push_event(turn_events, TurnEvent::Compacted(messages.clone()));
push_event(turn_events, TurnEvent::Done);
mark_done(in_flight);
}