chore: hapus semua source Rust + Cargo.toml/lock + .cargo (247 file .rs, migrasi ke TS/Bun)

This commit is contained in:
asepharyana
2026-09-02 22:51:10 +07:00
parent 319198d2f0
commit c165dff114
250 changed files with 0 additions and 33715 deletions
-55
View File
@@ -1,55 +0,0 @@
//! Authentication DTOs — login, register, and token refresh payloads.
use serde::{Deserialize, Serialize};
/// Request body for `POST /auth/login`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
/// Username or email identifier.
pub username: String,
/// Plaintext password.
pub password: String,
}
/// Request body for `POST /auth/register`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterRequest {
/// Desired username.
pub username: String,
/// Plaintext password (will be hashed server-side).
pub password: String,
/// Optional display name.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
/// Request body for `POST /auth/refresh`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshRequest {
/// The refresh token issued during login.
pub refresh_token: String,
}
/// Response body for auth endpoints (login, register, refresh).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
/// JWT access token (short-lived, typically 1 hour).
pub access_token: String,
/// JWT refresh token (long-lived, typically 7 days).
pub refresh_token: String,
/// Token type (always `"Bearer"`).
pub token_type: String,
/// Expiry of the access token in seconds.
pub expires_in: u64,
}
/// Claims exposed in the JWT payload, returned from introspection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimsResponse {
/// Subject identifier (username).
pub sub: String,
/// Issued-at timestamp (epoch seconds).
pub iat: u64,
/// Expiry timestamp (epoch seconds).
pub exp: u64,
}
-136
View File
@@ -1,136 +0,0 @@
//! Conversation DTOs — message history read/write payloads.
use serde::{Deserialize, Serialize};
use zesdex_domain::core::{ChatMessage, Conversation};
/// Request body for appending a message to a conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddMessageRequest {
/// Message role: `"user"` or `"assistant"`.
pub role: String,
/// Message content text.
pub content: String,
}
/// Response body for a single conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationResponse {
/// Session ID this conversation belongs to.
pub session_id: String,
/// Messages in the conversation.
pub messages: Vec<MessageResponse>,
/// Total message count.
pub message_count: usize,
/// Model identifier used for this conversation.
pub model: String,
/// System prompt in effect.
pub system_prompt: String,
/// Max tokens configuration.
pub max_tokens: Option<u32>,
/// Temperature configuration.
pub temperature: Option<f32>,
}
impl From<Conversation> for ConversationResponse {
fn from(c: Conversation) -> Self {
let message_count = c.len();
let messages: Vec<MessageResponse> =
c.messages.into_iter().map(MessageResponse::from).collect();
ConversationResponse {
session_id: c.session_id,
messages,
message_count,
model: c.model,
system_prompt: c.system_prompt,
max_tokens: c.max_tokens,
temperature: c.temperature,
}
}
}
/// A single message in a conversation response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageResponse {
/// Message role.
pub role: String,
/// Message content (None for assistant messages with only tool calls).
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Optional tool call information.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallResponse>>,
/// Optional tool call result identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl From<ChatMessage> for MessageResponse {
fn from(m: ChatMessage) -> Self {
let tool_calls = m.tool_calls.map(|calls| {
calls
.into_iter()
.map(|tc| ToolCallResponse {
id: tc.id,
function: ToolFunctionResponse {
name: tc.function.name,
arguments: tc.function.arguments.to_string(),
},
})
.collect()
});
MessageResponse {
role: m.role.to_string(),
content: m.content,
tool_calls,
tool_call_id: m.tool_call_id,
}
}
}
/// A tool call reference in a message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResponse {
/// Tool call ID.
pub id: String,
/// Function details.
pub function: ToolFunctionResponse,
}
/// A function invocation in a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionResponse {
/// Function name.
pub name: String,
/// JSON-encoded arguments.
pub arguments: String,
}
/// Request body for `POST /chat/completions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionRequest {
/// The session ID to attach this completion to.
pub session_id: String,
/// Message content (user message).
pub message: String,
/// Optional model override.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Optional max tokens override.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
/// Optional temperature override.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
}
/// Response body for `POST /chat/completions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionResponse {
/// The assistant's reply.
pub reply: String,
/// Total prompt tokens consumed.
pub prompt_tokens: u64,
/// Total completion tokens generated.
pub completion_tokens: u64,
}
-15
View File
@@ -1,15 +0,0 @@
//! Error response DTO — JSON body returned for all API errors.
use serde::Serialize;
/// Standardised error response body.
///
/// Returned for all non-successful API responses. Contains a human-readable
/// `message` and the HTTP status `code` for machine parsing.
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResponse {
/// Human-readable error description.
pub message: String,
/// HTTP status code (mirrors the response status).
pub code: u16,
}
-10
View File
@@ -1,10 +0,0 @@
//! Data Transfer Objects for the REST API.
//!
//! These types define the wire format for request bodies and response bodies.
//! They are intentionally independent of domain entities so the API contract
//! can evolve without coupling to the domain model.
pub mod auth;
pub mod conversation;
pub mod error;
pub mod session;
-57
View File
@@ -1,57 +0,0 @@
//! Session DTOs — create, list, and delete session payloads.
use serde::{Deserialize, Serialize};
use zesdex_domain::auth::Session;
/// Request body for `POST /sessions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSessionRequest {
/// Human-readable session title.
pub title: String,
}
/// Response body for a single session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionResponse {
/// Unique session identifier.
pub id: String,
/// Epoch-millis timestamp of creation.
pub created_at: i64,
/// Epoch-millis timestamp of last update.
pub updated_at: i64,
/// Human-readable title.
pub title: String,
/// Model identifier string.
pub model: String,
/// Number of messages in this session.
pub message_count: u32,
/// Whether the session has been archived.
pub archived: bool,
/// Optional AI-generated summary.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
impl From<Session> for SessionResponse {
fn from(s: Session) -> Self {
SessionResponse {
id: s.id,
created_at: s.created_at,
updated_at: s.updated_at,
title: s.title,
model: s.model,
message_count: s.message_count,
archived: s.archived,
summary: s.summary,
}
}
}
/// Response body for `GET /sessions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionListResponse {
/// All (non-archived) sessions.
pub sessions: Vec<SessionResponse>,
/// Total count of sessions returned.
pub total: usize,
}
-167
View File
@@ -1,167 +0,0 @@
//! Typed API error type with automatic HTTP response conversion.
//!
//! `ApiError` represents all possible failure modes of the REST API.
//! Each variant maps to an appropriate HTTP status code via `IntoResponse`,
//! producing a JSON body with a `message` field and an optional `code`.
//!
//! # Flow
//!
//! Handler returns `Result<T, ApiError>` → Axum calls `IntoResponse` →
//! HTTP response with appropriate status code and JSON error body.
//!
//! # Error mapping
//!
//! - `BadRequest` → 400
//! - `Unauthorized` → 401
//! - `NotFound` → 404
//! - `Conflict` → 409
//! - `Internal` → 500 (with `tracing::error!` log)
//! - `ChatProxy` → 502 (upstream LLM error)
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use crate::dto::error::ErrorResponse;
/// Typed API error with HTTP status code mapping.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
/// The request was malformed or contained invalid data.
#[error("Bad request: {0}")]
BadRequest(String),
/// Authentication failed or credentials are missing/invalid.
#[error("Unauthorized: {0}")]
Unauthorized(String),
/// The requested resource was not found.
#[error("Not found: {0}")]
NotFound(String),
/// The request conflicts with the current server state.
#[error("Conflict: {0}")]
Conflict(String),
/// The client has sent too many requests in a given time window.
#[error("Too many requests: {0}")]
TooManyRequests(String),
/// An unexpected internal error occurred.
#[error("Internal error: {0}")]
Internal(String),
/// The upstream LLM provider returned an error.
#[error("Chat proxy error: {0}")]
ChatProxy(String),
}
impl IntoResponse for ApiError {
/// Convert `ApiError` into an HTTP response with an appropriate status
/// code and a structured JSON body.
///
/// Internal errors are logged at `error` level before returning a generic
/// 500 response (to avoid leaking internal details).
fn into_response(self) -> Response {
let (status, user_message) = match &self {
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
ApiError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg.clone()),
ApiError::Internal(msg) => {
tracing::error!(error = %msg, "Internal server error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"An internal error occurred".to_string(),
)
}
ApiError::ChatProxy(msg) => {
tracing::error!(error = %msg, "Chat proxy error");
(
StatusCode::BAD_GATEWAY,
format!("Upstream LLM error: {msg}"),
)
}
};
let body = ErrorResponse {
message: user_message,
code: status.as_u16(),
};
(status, Json(json!(body))).into_response()
}
}
// ---------------------------------------------------------------------------
// From impls — convert domain/infrastructure errors into ApiError
// ---------------------------------------------------------------------------
impl From<zesdex_domain::error::DomainError> for ApiError {
/// Map domain repository errors to API errors.
///
/// - `NotFound` → `ApiError::NotFound`
/// - `Conflict` → `ApiError::Conflict`
/// - `InvalidId` → `ApiError::BadRequest`
/// - All others → `ApiError::Internal`
fn from(e: zesdex_domain::error::DomainError) -> Self {
match e {
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
_ => ApiError::Internal(e.to_string()),
}
}
}
impl From<zesdex_domain::auth::ServiceError> for ApiError {
/// Map IAM service errors to API errors.
fn from(e: zesdex_domain::auth::ServiceError) -> Self {
match e {
zesdex_domain::auth::ServiceError::Repository(repo_err) => match repo_err {
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
_ => ApiError::Internal(repo_err.to_string()),
},
zesdex_domain::auth::ServiceError::InvalidConfig(msg) => ApiError::BadRequest(msg),
zesdex_domain::auth::ServiceError::StateMismatch => {
ApiError::Unauthorized("OAuth state mismatch — possible CSRF attack".into())
}
zesdex_domain::auth::ServiceError::OAuthProvider(msg) => ApiError::ChatProxy(msg),
zesdex_domain::auth::ServiceError::Other(msg) => ApiError::Internal(msg),
}
}
}
impl From<zesdex_domain::cms::ServiceError> for ApiError {
/// Map CMS service errors to API errors.
fn from(e: zesdex_domain::cms::ServiceError) -> Self {
match e {
zesdex_domain::cms::ServiceError::Repository(repo_err) => match repo_err {
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
_ => ApiError::Internal(repo_err.to_string()),
},
zesdex_domain::cms::ServiceError::InvalidInput(msg) => ApiError::BadRequest(msg),
zesdex_domain::cms::ServiceError::Other(msg) => ApiError::Internal(msg),
}
}
}
impl From<anyhow::Error> for ApiError {
/// Fallback conversion: log the error and return a generic internal error.
fn from(e: anyhow::Error) -> Self {
tracing::error!(error = %e, "Unhandled error");
ApiError::Internal(e.to_string())
}
}
impl From<jsonwebtoken::errors::Error> for ApiError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
ApiError::Unauthorized(format!("Invalid token: {e}"))
}
}
-285
View File
@@ -1,285 +0,0 @@
//! Authentication handlers — login, register, and token refresh.
//!
//! # Endpoints
//!
//! - `POST /auth/login` — authenticate with username/password, returns JWT
//! - `POST /auth/register` — create a new user account
//! - `POST /auth/refresh` — exchange a refresh token for a new access token
//!
//! # Flow
//!
//! Login: validate input → verify password → generate token pair → return.
//! Register: validate input → check uniqueness → hash password → persist → login.
//! Refresh: decode refresh token → verify → generate new token pair.
use std::sync::Arc;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::routing::post;
use axum::{Json, Router};
use zesdex_application::ports::{PasswordService, TokenService};
use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterRequest};
use crate::error::ApiError;
use crate::state::ApiState;
/// Login/register brute-force protection: 20 attempts per 10-minute window
/// per client IP.
const AUTH_RATE_LIMIT_MAX: u32 = 20;
const AUTH_RATE_LIMIT_WINDOW_SECS: u64 = 600;
/// Build the auth sub-router (`/auth/*`).
pub fn router() -> Router<Arc<ApiState>> {
Router::new()
.route("/login", post(login_handler))
.route("/register", post(register_handler))
.route("/refresh", post(refresh_handler))
}
/// Extract a coarse client identity from the request headers (IP via
/// X-Forwarded-For fallback). Used as the rate-limit key.
fn client_id(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
/// Enforce the auth rate limit, returning `true` if the request is allowed.
fn rate_limited(state: &ApiState, headers: &HeaderMap) -> bool {
!state
.auth_rate_limiter
.check_rate_limit(
&client_id(headers),
AUTH_RATE_LIMIT_MAX,
AUTH_RATE_LIMIT_WINDOW_SECS,
)
.unwrap_or(true)
}
/// POST /auth/login — authenticate and issue JWT tokens.
///
/// ## Flow
///
/// 1. Deserialize `LoginRequest`.
/// 2. Load the stored user credentials from the users store.
/// 3. Verify the password against the stored hash.
/// 4. Generate an access + refresh token pair.
/// 5. Return `AuthResponse`.
///
/// ## Errors
///
/// - `400 Bad Request` — missing or empty fields.
/// - `401 Unauthorized` — invalid username or password.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn login_handler(
State(state): State<Arc<ApiState>>,
headers: HeaderMap,
Json(req): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// Rate-limit login attempts (brute-force protection).
if rate_limited(&state, &headers) {
return Err(ApiError::TooManyRequests(
"Too many login attempts, try again later".into(),
));
}
// Validate input
if req.username.is_empty() || req.password.is_empty() {
return Err(ApiError::BadRequest(
"Username and password are required".into(),
));
}
// Load the users database from the store
let users_path = state.store_base_dir.join("users.json");
let users: std::collections::HashMap<String, String> = if users_path.exists() {
let content = std::fs::read_to_string(&users_path)
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
serde_json::from_str(&content)
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
} else {
return Err(ApiError::Unauthorized(
"Invalid username or password".into(),
));
};
// Look up the user
let stored_hash = users
.get(&req.username)
.ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?;
// Verify password
let valid = state
.password_service
.verify(&req.password, stored_hash)
.await
.map_err(|e| ApiError::Internal(format!("Password verification failed: {e}")))?;
if !valid {
return Err(ApiError::Unauthorized(
"Invalid username or password".into(),
));
}
// Generate tokens
let (access_token, refresh_token) = state
.token_service
.generate_tokens(&req.username)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
Ok(Json(AuthResponse {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: state.token_service.access_token_expiry_secs,
}))
}
/// POST /auth/register — create a new user account.
///
/// ## Flow
///
/// 1. Deserialize `RegisterRequest`.
/// 2. Check username availability (load users, reject if exists).
/// 3. Hash the password using Argon2id.
/// 4. Persist the new username + hash.
/// 5. Generate an access + refresh token pair.
/// 6. Return `AuthResponse`.
///
/// ## Errors
///
/// - `400 Bad Request` — missing or invalid fields.
/// - `409 Conflict` — username already taken.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn register_handler(
State(state): State<Arc<ApiState>>,
headers: HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// Rate-limit registration (abuse protection).
if rate_limited(&state, &headers) {
return Err(ApiError::TooManyRequests(
"Too many registration attempts, try again later".into(),
));
}
// Validate input
if req.username.is_empty() {
return Err(ApiError::BadRequest("Username is required".into()));
}
if req.password.len() < 6 {
return Err(ApiError::BadRequest(
"Password must be at least 6 characters".into(),
));
}
// Compute the password hash first (async, no lock held).
let hash = state
.password_service
.hash(&req.password)
.await
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?;
// Serialize read-modify-write of users.json to avoid losing concurrent
// registers (TOCTOU race). The lock is held only across the sync
// file operations — never across an `.await` (keeps the future `Send`).
let _users_guard = state
.users_lock
.lock()
.map_err(|_| ApiError::Internal("users lock poisoned".into()))?;
// Load existing users
let users_path = state.store_base_dir.join("users.json");
let mut users: std::collections::HashMap<String, String> = if users_path.exists() {
let content = std::fs::read_to_string(&users_path)
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
serde_json::from_str(&content)
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
} else {
std::collections::HashMap::new()
};
// Check uniqueness
if users.contains_key(&req.username) {
return Err(ApiError::Conflict("Username already exists".into()));
}
// Persist
users.insert(req.username.clone(), hash);
let content = serde_json::to_string_pretty(&users)
.map_err(|e| ApiError::Internal(format!("Failed to serialize users: {e}")))?;
std::fs::write(&users_path, &content)
.map_err(|e| ApiError::Internal(format!("Failed to write users: {e}")))?;
// Generate tokens
let (access_token, refresh_token) = state
.token_service
.generate_tokens(&req.username)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
Ok(Json(AuthResponse {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: state.token_service.access_token_expiry_secs,
}))
}
/// POST /auth/refresh — exchange a refresh token for a new access token.
///
/// ## Flow
///
/// 1. Deserialize `RefreshRequest`.
/// 2. Verify the refresh token's signature and extract the subject.
/// 3. Generate a fresh access + refresh token pair.
/// 4. Return `AuthResponse`.
///
/// ## Errors
///
/// - `400 Bad Request` — missing refresh token.
/// - `401 Unauthorized` — invalid or expired refresh token.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn refresh_handler(
State(state): State<Arc<ApiState>>,
headers: HeaderMap,
Json(req): Json<RefreshRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// Rate-limit refresh attempts.
if rate_limited(&state, &headers) {
return Err(ApiError::TooManyRequests(
"Too many requests, try again later".into(),
));
}
if req.refresh_token.is_empty() {
return Err(ApiError::BadRequest("Refresh token is required".into()));
}
// Verify the refresh token and extract the subject
let sub = state
.token_service
.verify_refresh_token(&req.refresh_token)
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
// Generate a fresh token pair
let (access_token, new_refresh_token) = state
.token_service
.generate_tokens(&sub)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
Ok(Json(AuthResponse {
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
expires_in: state.token_service.access_token_expiry_secs,
}))
}
-166
View File
@@ -1,166 +0,0 @@
//! LLM chat completion proxy handler.
//!
//! # Endpoints
//!
//! - `POST /chat/completions` — proxy a chat completion request to the LLM
//! provider, optionally persisting the conversation.
//!
//! # Flow
//!
//! 1. Deserialize `ChatCompletionRequest`.
//! 2. Load the existing conversation for the given session (create if absent).
//! 3. Append the user's message to the conversation.
//! 4. Call the LLM provider via `LlmClient`.
//! 5. Append the assistant's reply to the conversation.
//! 6. Persist the updated conversation.
//! 7. Return `ChatCompletionResponse`.
use std::sync::Arc;
use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};
use zesdex_domain::cms::ConversationService;
use zesdex_domain::core::{ChatMessage, Role};
use crate::dto::conversation::{ChatCompletionRequest, ChatCompletionResponse};
use crate::error::ApiError;
use crate::state::ApiState;
/// Build the chat sub-router (`/chat/*`).
pub fn router() -> Router<Arc<ApiState>> {
Router::new().route("/completions", post(chat_completions_handler))
}
/// POST /chat/completions — proxy to LLM provider.
///
/// ## Flow
///
/// 1. Deserialize the request body.
/// 2. Load the conversation for the given `session_id`.
/// 3. Append the user's message to the conversation.
/// 4. Call the LLM (non-streaming) using `LlmClient`.
/// 5. Append the assistant's response.
/// 6. Persist the conversation.
/// 7. Return the assistant's reply and token usage.
///
/// ## Errors
///
/// - `400 Bad Request` — missing session_id or message.
/// - `502 Bad Gateway` — upstream LLM provider error.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn chat_completions_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<ChatCompletionRequest>,
) -> Result<Json<ChatCompletionResponse>, ApiError> {
// Validate input
if req.session_id.is_empty() {
return Err(ApiError::BadRequest("session_id is required".into()));
}
if req.message.is_empty() {
return Err(ApiError::BadRequest("message is required".into()));
}
// Load or create the conversation
let mut conversation = state
.conversation_service
.load_conversation(&req.session_id)
.unwrap_or_else(|_| {
// Create a new empty conversation
zesdex_domain::core::Conversation {
session_id: req.session_id.clone(),
messages: Vec::new(),
model: req
.model
.clone()
.unwrap_or_else(|| state.llm_client.model.clone()),
system_prompt: String::new(),
max_tokens: None,
temperature: None,
}
});
// Set model if overridden
if let Some(ref model) = req.model {
conversation.model.clone_from(model);
}
// Append the user's message
let user_msg = ChatMessage {
role: Role::User,
content: Some(req.message.clone()),
tool_calls: None,
tool_call_id: None,
name: None,
};
conversation.push(user_msg.clone());
// Build message history for the LLM
let messages: Vec<ChatMessage> = conversation.messages.clone();
// Get model from conversation
let model = if conversation.model.is_empty() {
state.llm_client.model.clone()
} else {
conversation.model.clone()
};
// Call the LLM provider (non-streaming)
//
// 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 a match binding.
//
// We use an uninitialized let-binding for `temp_client` so the
// compiler can see it's assigned at most once, avoiding
// `#[allow(unused_assignments)]`.
let temp_client;
let llm_client = if model == state.llm_client.model {
&state.llm_client
} else {
temp_client = zesdex_infrastructure::llm::provider::LlmClient::new(
state.llm_client.api_key.clone(),
model,
Some(state.llm_client.base_url.clone()),
);
&temp_client
};
use zesdex_application::ports::ProviderService;
let (response, usage) = llm_client
.chat(
&messages,
None, // No tool definitions for basic chat
req.max_tokens,
req.temperature,
)
.await
.map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?;
let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0));
// The response content may be None if only tool calls were returned
let reply_text = response.content.unwrap_or_default();
// Append the assistant's reply
let assistant_msg = ChatMessage {
role: Role::Assistant,
content: Some(reply_text.clone()),
tool_calls: response.tool_calls,
tool_call_id: response.tool_call_id,
name: None,
};
state
.conversation_service
.add_message(&mut conversation, assistant_msg)
.map_err(|e| ApiError::Internal(format!("Failed to persist conversation: {e}")))?;
Ok(Json(ChatCompletionResponse {
reply: reply_text,
prompt_tokens,
completion_tokens,
}))
}
@@ -1,149 +0,0 @@
//! Conversation message-history handlers.
//!
//! # Endpoints
//!
//! - `GET /sessions/:id/conversations` — get conversation for a session
//! - `POST /sessions/:id/conversations` — append a message to a session
//! - `DELETE /sessions/:id/conversations/:cid` — delete a conversation message
//!
//! # Flow
//!
//! Each handler extracts the session ID from the path, delegates to the
//! `ConversationServiceImpl`, and maps results to HTTP responses.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use zesdex_domain::cms::ConversationService;
use zesdex_domain::core::ChatMessage;
use crate::dto::conversation::{AddMessageRequest, ConversationResponse};
use crate::error::ApiError;
use crate::state::ApiState;
/// GET /sessions/:id/conversations — fetch the full conversation for a session.
///
/// ## Flow
///
/// 1. Extract session ID from the path.
/// 2. Load the conversation via `ConversationServiceImpl`.
/// 3. Return the conversation with all messages.
///
/// ## Errors
///
/// - `404 Not Found` — no conversation exists for this session.
#[tracing::instrument(skip(state))]
pub async fn get_conversation_handler(
State(state): State<Arc<ApiState>>,
Path(id): Path<String>,
) -> Result<Json<ConversationResponse>, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
let conversation = state.conversation_service.load_conversation(&id)?;
Ok(Json(ConversationResponse::from(conversation)))
}
/// POST /sessions/:id/conversations — add a message to a session conversation.
///
/// ## Flow
///
/// 1. Extract session ID from the path.
/// 2. Deserialize `AddMessageRequest`.
/// 3. Build a `ChatMessage` from the request.
/// 4. Load the conversation, append the message, persist.
/// 5. Return the updated conversation.
///
/// ## Errors
///
/// - `400 Bad Request` — invalid message format.
/// - `404 Not Found` — session not found.
#[tracing::instrument(skip(state))]
pub async fn add_message_handler(
State(state): State<Arc<ApiState>>,
Path(id): Path<String>,
Json(req): Json<AddMessageRequest>,
) -> Result<(axum::http::StatusCode, Json<ConversationResponse>), ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
if req.content.is_empty() {
return Err(ApiError::BadRequest("Message content is required".into()));
}
// Parse role
let role = match req.role.to_lowercase().as_str() {
"user" => zesdex_domain::core::Role::User,
"assistant" => zesdex_domain::core::Role::Assistant,
_ => return Err(ApiError::BadRequest(format!("Invalid role: {}", req.role))),
};
let msg = ChatMessage {
role,
content: Some(req.content),
tool_calls: None,
tool_call_id: None,
name: None,
};
// Load conversation and add message
let mut conversation = state.conversation_service.load_conversation(&id)?;
state
.conversation_service
.add_message(&mut conversation, msg)?;
Ok((
axum::http::StatusCode::OK,
Json(ConversationResponse::from(conversation)),
))
}
/// DELETE /sessions/:id/conversations/:cid — delete 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
///
/// - `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)>,
) -> Result<axum::http::StatusCode, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
// 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)?;
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)?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
@@ -1,18 +0,0 @@
//! Health-check endpoint.
//!
//! `GET /health` — returns a simple `{"status": "ok"}` response used by
//! load balancers, orchestrators, and monitoring tools to verify the API
//! server is running.
use axum::Json;
use serde_json::{json, Value};
/// Handle `GET /health`.
///
/// Returns a 200 OK response with `{"status": "ok"}`.
///
/// This endpoint requires no authentication and has no side effects.
#[tracing::instrument(skip_all)]
pub async fn health() -> Json<Value> {
Json(json!({"status": "ok"}))
}
-10
View File
@@ -1,10 +0,0 @@
//! API route handler modules.
//!
//! Each sub-module corresponds to a resource group and exposes a `router()`
//! function that returns an `axum::Router` scoped to that resource's prefix.
pub mod auth;
pub mod chat;
pub mod conversations;
pub mod health;
pub mod sessions;
@@ -1,100 +0,0 @@
//! Session management handlers.
//!
//! # Endpoints
//!
//! - `GET /sessions` — list all sessions (optionally filtered)
//! - `POST /sessions` — create a new session
//! - `DELETE /sessions/:id` — archive/close a session
//!
//! # Flow
//!
//! Each handler extracts the shared `ApiState`, delegates to the
//! `SessionServiceImpl`, and maps results to HTTP responses with DTOs.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use zesdex_domain::auth::{SessionId, SessionService};
use crate::dto::session::{CreateSessionRequest, SessionListResponse, SessionResponse};
use crate::error::ApiError;
use crate::state::ApiState;
/// Build the sessions sub-router (`/sessions/*`).
pub fn router() -> Router<Arc<ApiState>> {
Router::new()
.route("/", get(list_sessions_handler))
.route("/", post(create_session_handler))
.route("/{id}", delete(delete_session_handler))
}
/// GET /sessions — list all sessions.
///
/// Returns a list of non-archived sessions sorted by creation time.
#[tracing::instrument(skip(state))]
pub async fn list_sessions_handler(
State(state): State<Arc<ApiState>>,
) -> Result<Json<SessionListResponse>, ApiError> {
let sessions = state.session_service.list_all()?;
let session_responses: Vec<SessionResponse> =
sessions.into_iter().map(SessionResponse::from).collect();
let total = session_responses.len();
Ok(Json(SessionListResponse {
sessions: session_responses,
total,
}))
}
/// POST /sessions — create a new session.
///
/// ## Flow
///
/// 1. Deserialize `CreateSessionRequest`.
/// 2. Delegate to `SessionServiceImpl::create_session`.
/// 3. Return the created session as `SessionResponse` with 201 Created.
#[tracing::instrument(skip(state))]
pub async fn create_session_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<CreateSessionRequest>,
) -> Result<(axum::http::StatusCode, Json<SessionResponse>), ApiError> {
if req.title.trim().is_empty() {
return Err(ApiError::BadRequest("Session title is required".into()));
}
let session = state.session_service.create_session(&req.title)?;
Ok((
axum::http::StatusCode::CREATED,
Json(SessionResponse::from(session)),
))
}
/// DELETE /sessions/:id — archive/close a session.
///
/// ## Flow
///
/// 1. Extract the session ID from the path.
/// 2. Validate the ID format.
/// 3. Delegate to `SessionServiceImpl::archive_session`.
/// 4. Return 204 No Content.
#[tracing::instrument(skip(state))]
pub async fn delete_session_handler(
State(state): State<Arc<ApiState>>,
Path(id): Path<String>,
) -> Result<axum::http::StatusCode, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
let session_id = SessionId::new(&id)
.map_err(|e| ApiError::BadRequest(format!("Invalid session ID: {e}")))?;
state.session_service.archive_session(session_id)?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
-113
View File
@@ -1,113 +0,0 @@
//! # Zesdex REST API — Axum HTTP server
//!
//! Provides RESTful endpoints for the Zesdex application, enabling
//! web clients, mobile apps, and third-party integrations.
//!
//! ## Architecture
//!
//! ```text
//! src/
//! ├── lib.rs — Module declarations, re-exports, router builder
//! ├── state.rs — ApiState with concrete service implementations
//! ├── error.rs — ApiError enum + IntoResponse
//! ├── dto/ — Request/response DTOs (serde)
//! ├── handlers/ — Axum route handlers
//! └── middleware/ — Tower layers (JWT auth, etc.)
//! ```
//!
//! ## Flow
//!
//! 1. `build_router()` constructs an Axum `Router` with all routes nested.
//! 2. Each handler receives `State<Arc<ApiState>>` or direct extractors.
//! 3. Handlers delegate to application-layer service implementations.
//! 4. Domain/infrastructure errors are mapped to `ApiError` → HTTP status codes.
pub mod dto;
pub mod error;
pub mod handlers;
pub mod middleware;
pub mod state;
pub use error::ApiError;
pub use state::ApiState;
use axum::Router;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
/// Build the API router with all routes registered.
///
/// Flow: create CORS layer → build sub-routers for each resource → nest
/// them under `/api/v1` → attach shared state → return.
///
/// ## Arguments
/// * `state` — shared application state (wrapped in `Arc` for clone-free sharing)
///
/// ## Example
/// ```ignore
/// let state = ApiState::new("/path/to/data");
/// let app = build_router(state);
/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
/// axum::serve(listener, app).await.unwrap();
/// ```
pub fn build_router(state: ApiState) -> Router {
let shared_state: Arc<ApiState> = Arc::new(state);
// CORS layer — permissive for local daemon / development use
let cors = CorsLayer::permissive();
// JWT auth middleware — protects session/chat routes.
// Auth (login/register/refresh) and health endpoints stay public.
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
// Combine all sub-routers under a versioned prefix
Router::new()
.nest("/api/v1/auth", auth_router())
.nest("/api/v1/health", health_router())
.nest("/api/v1", protected_router().layer(jwt_auth))
.layer(cors)
.with_state(shared_state)
}
/// Auth + health sub-routers — publicly accessible (no JWT required).
fn auth_router() -> Router<Arc<ApiState>> {
handlers::auth::router()
}
fn health_router() -> Router<Arc<ApiState>> {
Router::new().route("/", axum::routing::get(handlers::health::health))
}
/// Protected sub-router — sessions + chat, guarded by JWT auth layer.
fn protected_router() -> Router<Arc<ApiState>> {
use handlers::{chat, conversations, sessions};
// Sessions router combines session CRUD + nested conversations
let sessions_router = Router::new()
.route("/", axum::routing::get(sessions::list_sessions_handler))
.route("/", axum::routing::post(sessions::create_session_handler))
.route(
"/{id}",
axum::routing::delete(sessions::delete_session_handler),
)
// Conversations are sub-resources of sessions
.route(
"/{id}/conversations",
axum::routing::get(conversations::get_conversation_handler),
)
.route(
"/{id}/conversations",
axum::routing::post(conversations::add_message_handler),
)
.route(
"/{id}/conversations/{cid}",
axum::routing::delete(conversations::delete_message_handler),
);
Router::new()
.nest("/sessions", sessions_router)
.nest("/chat", chat::router())
}
// Re-export commonly used types at the crate root for ergonomic access.
pub use axum::http::StatusCode;
-152
View File
@@ -1,152 +0,0 @@
//! JWT authentication middleware for Axum.
//!
//! Validates the `Authorization: Bearer <token>` header on every protected
//! request. Injects the validated subject claim into request extensions for
//! downstream handlers to consume.
//!
//! # Flow
//!
//! ```text
//! Request → JwtAuthLayer → extract Bearer token → verify JWT → inject claims
//! → inner service → Response
//! ```
//!
//! If the token is missing, expired, or has an invalid signature the request
//! is rejected with 401 Unauthorized before reaching any handler.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use serde_json::json;
use tower::{Layer, Service};
use crate::state::ApiState;
/// Claims extracted from a valid JWT, injected into request extensions.
#[derive(Debug, Clone, Serialize)]
pub struct JwtClaims {
/// Subject identifier (username/user ID).
pub sub: String,
}
/// Tower Layer that produces `JwtAuthMiddleware` services.
#[derive(Debug, Clone)]
pub struct JwtAuthLayer {
/// HMAC secret used to verify JWT signatures (reference into `ApiState`).
state: Arc<ApiState>,
}
impl JwtAuthLayer {
/// Create a new JWT auth layer with the given shared API state.
pub fn new(state: Arc<ApiState>) -> Self {
Self { state }
}
}
impl<S> Layer<S> for JwtAuthLayer {
type Service = JwtAuthMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
JwtAuthMiddleware {
inner,
state: self.state.clone(),
}
}
}
/// Tower Service that validates JWT Bearer tokens before forwarding.
#[derive(Debug, Clone)]
pub struct JwtAuthMiddleware<S> {
inner: S,
state: Arc<ApiState>,
}
impl<S, ReqBody> Service<Request<ReqBody>> for JwtAuthMiddleware<S>
where
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
// Extract the Authorization header
let auth_header = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let secret = self.state.jwt_secret.clone();
if let Some(auth_value) = auth_header {
// Expect "Bearer <token>"
if let Some(token) = auth_value.strip_prefix("Bearer ") {
match zesdex_infrastructure::auth::jwt::verify_token(&secret, token) {
Ok(claims) => {
// Reject refresh tokens on protected routes — only access
// tokens are acceptable here.
if claims.token_type != zesdex_infrastructure::auth::jwt::TokenType::Access
{
let response = (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Invalid token",
"detail": "refresh tokens are not accepted on protected routes"
})),
)
.into_response();
return Box::pin(async move { Ok(response) });
}
// Inject claims as extension for downstream handlers
let mut req = req;
req.extensions_mut().insert(JwtClaims { sub: claims.sub });
let fut = self.inner.call(req);
return Box::pin(fut);
}
Err(e) => {
let response = (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Invalid token",
"detail": e.to_string()
})),
)
.into_response();
return Box::pin(async move { Ok(response) });
}
}
}
}
// No valid Authorization header
let response = (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "Missing or invalid Authorization header"})),
)
.into_response();
Box::pin(async move { Ok(response) })
}
}
/// Helper: check if a request has a valid JWT in its Authorization header.
///
/// Intended for use in middleware layers or route guards that need quick
/// authentication verification without extracting the full claims.
pub fn is_authenticated(req: &Request<Body>) -> bool {
req.extensions().get::<JwtClaims>().is_some()
}
@@ -1,6 +0,0 @@
//! Axum middleware layers for the REST API.
//!
//! Provides tower `Layer` implementations for cross-cutting concerns:
//! - `auth` — JWT-based authentication layer
pub mod auth;
-311
View File
@@ -1,311 +0,0 @@
//! Shared application state for the REST API server.
//!
//! `ApiState` holds concrete service implementations wired to infrastructure
//! adapters. It is constructed at the composition root and shared across all
//! handlers via Axum's `State` extractor (wrapped in `Arc`).
//!
//! # Flow
//!
//! 1. `ApiState::new(base_dir)` creates all services with their concrete repos.
//! 2. `build_router()` wraps it in `Arc` and passes it to the Axum `Router`.
//! 3. Handlers extract `State<Arc<ApiState>>` and delegate to the services.
//!
//! # Port trait implementations
//!
//! This module also provides simple wrapper types that implement the
//! application-layer port traits using infrastructure functions:
//!
//! - `Argon2PasswordService` — implements `PasswordService` via
//! `infrastructure::auth::password`
//! - `JwtTokenService` — implements `TokenService` via
//! `infrastructure::auth::jwt`
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use zesdex_application::ports::{PasswordService, TokenService};
// ---------------------------------------------------------------------------
// Port trait implementations (wrap infrastructure free functions)
// ---------------------------------------------------------------------------
/// Password-hashing service backed by Argon2id (infrastructure).
///
/// Delegates to `zesdex_infrastructure::auth::password::{hash_password, verify_password}`.
#[derive(Clone)]
pub struct Argon2PasswordService;
impl fmt::Debug for Argon2PasswordService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Argon2PasswordService").finish()
}
}
impl PasswordService for Argon2PasswordService {
/// Hash a plaintext password using Argon2id with a random salt.
fn hash(&self, password: &str) -> impl Future<Output = anyhow::Result<String>> + Send {
zesdex_infrastructure::auth::password::hash_password(password)
}
/// Verify a plaintext password against a stored PHC string.
fn verify(
&self,
password: &str,
hash: &str,
) -> impl Future<Output = anyhow::Result<bool>> + Send {
zesdex_infrastructure::auth::password::verify_password(password, hash)
}
}
/// JWT token service backed by HS256 (infrastructure).
///
/// Delegates to `zesdex_infrastructure::auth::jwt::{create_token, verify_token}`.
#[derive(Clone)]
pub struct JwtTokenService {
/// HMAC secret key used for signing and verification.
pub secret: String,
/// Token expiry in seconds (default: 3600 = 1 hour).
pub access_token_expiry_secs: u64,
/// Refresh token expiry in seconds (default: 604800 = 7 days).
pub refresh_token_expiry_secs: u64,
}
impl fmt::Debug for JwtTokenService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JwtTokenService")
.field("access_token_expiry_secs", &self.access_token_expiry_secs)
.field("refresh_token_expiry_secs", &self.refresh_token_expiry_secs)
.finish_non_exhaustive()
}
}
impl JwtTokenService {
/// Create a new JWT service with the given HMAC secret.
pub fn new(secret: impl Into<String>) -> Self {
JwtTokenService {
secret: secret.into(),
access_token_expiry_secs: 3600,
refresh_token_expiry_secs: 604800,
}
}
}
impl TokenService for JwtTokenService {
/// Generate an access + refresh token pair for the given subject.
fn generate_tokens(&self, sub: &str) -> anyhow::Result<(String, String)> {
use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims, TokenType};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Access token
let access_claims = JwtClaims::new(
sub.to_string(),
now + self.access_token_expiry_secs,
TokenType::Access,
None,
);
let access_token = create_token(&self.secret, access_claims)?;
// Refresh token (longer-lived)
let refresh_claims = JwtClaims::new(
sub.to_string(),
now + self.refresh_token_expiry_secs,
TokenType::Refresh,
None,
);
let refresh_token = create_token(&self.secret, refresh_claims)?;
Ok((access_token, refresh_token))
}
/// Verify an access token and return the subject claim.
fn verify_access_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::verify_token;
let claims = verify_token(&self.secret, token)?;
Ok(claims.sub)
}
/// Verify a refresh token and return the subject claim.
///
/// Enforces that the presented token is a **refresh** token (`typ =
/// "refresh"`) — an access token presented here is rejected, closing
/// the replay-window escalation where a stolen 1-hour access token
/// could otherwise be exchanged for a fresh 7-day credential.
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::{verify_token, TokenType};
let claims = verify_token(&self.secret, token)?;
if claims.token_type != TokenType::Refresh {
anyhow::bail!("token is not a refresh token");
}
Ok(claims.sub)
}
}
// ---------------------------------------------------------------------------
// ApiState
// ---------------------------------------------------------------------------
/// Shared application state for the REST API server.
///
/// Holds all service implementations, repository instances, and configuration
/// needed by the HTTP handlers. Constructed once at startup and shared
/// across all requests via `Arc<ApiState>`.
///
/// `ApiState` does NOT derive `Clone` or `Debug` because the inner service
/// types may not implement those traits. It is always wrapped in `Arc`.
pub struct ApiState {
/// Base directory for all Zesdex data stores (sessions, settings, etc.).
pub store_base_dir: PathBuf,
/// JWT secret key for token signing/verification.
pub jwt_secret: String,
// -----------------------------------------------------------------------
// Service implementations (application-layer use cases)
// -----------------------------------------------------------------------
/// Session lifecycle management (create, list, archive).
pub session_service: zesdex_application::auth::SessionServiceImpl<
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository,
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository,
>,
/// Conversation message history CRUD.
pub conversation_service: zesdex_application::cms::ConversationServiceImpl<
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository,
>,
/// Settings load/save.
pub settings_service: zesdex_application::cms::SettingsServiceImpl<
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository,
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository,
>,
/// Long-term memory CRUD.
pub memory_service: zesdex_application::cms::MemoryServiceImpl<
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository,
>,
// -----------------------------------------------------------------------
// Port trait implementations (infrastructure wrappers)
// -----------------------------------------------------------------------
/// Argon2id password hashing and verification.
pub password_service: Argon2PasswordService,
/// HS256 JWT token generation and verification.
pub token_service: JwtTokenService,
/// Shared sliding-window limiter for auth endpoints (login/register/refresh).
pub auth_rate_limiter: zesdex_infrastructure::middleware::rate_limit::RateLimiter,
/// Serializes read-modify-write of `users.json` so concurrent register
/// requests cannot lose writes (TOCTOU race).
pub users_lock: Arc<std::sync::Mutex<()>>,
/// LLM provider client for chat completions.
pub llm_client: zesdex_infrastructure::llm::provider::LlmClient,
}
impl fmt::Debug for ApiState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiState")
.field("store_base_dir", &self.store_base_dir)
.field("jwt_secret", &"**redacted**")
.field("session_service", &"SessionServiceImpl { .. }")
.field("conversation_service", &"ConversationServiceImpl { .. }")
.field("settings_service", &"SettingsServiceImpl { .. }")
.field("memory_service", &"MemoryServiceImpl { .. }")
.field("password_service", &self.password_service)
.field("token_service", &self.token_service)
.field("llm_client", &"LlmClient { .. }")
.finish()
}
}
impl ApiState {
/// Construct a new API state with all services wired to their default
/// infrastructure implementations.
///
/// ## Arguments
/// * `base_dir` — the Zesdex data store root directory (sessions, settings, etc.)
/// * `jwt_secret` — HMAC secret for JWT signing/verification
/// * `llm_api_key` — API key for the LLM provider
/// * `llm_model` — model identifier string
/// * `llm_base_url` — optional custom API base URL
///
/// ## Flow
///
/// Creates concrete repository instances → wraps them in application-layer
/// service implementations → stores everything in `ApiState`.
pub fn new(
base_dir: PathBuf,
jwt_secret: impl Into<String>,
llm_api_key: impl Into<String>,
llm_model: impl Into<String>,
llm_base_url: Option<String>,
) -> Self {
let jwt_secret = jwt_secret.into();
let sessions_dir = base_dir.join("sessions");
let memory_dir = base_dir.join("memories");
// IAM repositories
let session_repo =
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository;
let lock_repo =
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository;
// CMS repositories
let conversation_repo =
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository;
let settings_repo =
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository;
let app_config_repo =
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository;
let memory_repo =
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository;
// Application-layer services
let session_service = zesdex_application::auth::SessionServiceImpl::new(
session_repo,
lock_repo,
base_dir.clone(),
);
let conversation_service =
zesdex_application::cms::ConversationServiceImpl::new(conversation_repo, sessions_dir);
let settings_service = zesdex_application::cms::SettingsServiceImpl::new(
settings_repo,
app_config_repo,
base_dir.clone(),
);
let memory_service =
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
let token_service = JwtTokenService::new(&jwt_secret);
let auth_rate_limiter = zesdex_infrastructure::middleware::rate_limit::RateLimiter::new();
let users_lock = Arc::new(std::sync::Mutex::new(()));
let llm_client = zesdex_infrastructure::llm::provider::LlmClient::new(
llm_api_key.into(),
llm_model.into(),
llm_base_url,
);
ApiState {
store_base_dir: base_dir,
jwt_secret,
session_service,
conversation_service,
settings_service,
memory_service,
password_service: Argon2PasswordService,
token_service,
auth_rate_limiter,
users_lock,
llm_client,
}
}
}