From 714b4617ddabdc7ebf4509424c62347c16183444 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 20 Jul 2026 06:53:01 +0700 Subject: [PATCH] Refactor CMS and IAM modules: restructure presentation and command layers - Removed HTTP adapter module from CMS infrastructure. - Updated CMS infrastructure module to exclude HTTP. - Introduced presentation layer in CMS with DTOs and handlers for REST API. - Added command types for CMS domain operations to encapsulate input data. - Created typed error handling for CMS presentation layer. - Implemented handlers for CMS REST API endpoints. - Removed HTTP DTOs and handlers from IAM infrastructure. - Introduced command types for IAM domain operations. - Created presentation layer in IAM with DTOs and handlers for OAuth flow. - Implemented typed error handling for IAM presentation layer. --- .../src/app/runtime/actions/turn.rs | 38 ++--- crates/zesdex-cms/src/domain/commands.rs | 153 ++++++++++++++++++ crates/zesdex-cms/src/domain/mod.rs | 1 + .../zesdex-cms/src/infrastructure/http/mod.rs | 28 ---- crates/zesdex-cms/src/infrastructure/mod.rs | 2 - crates/zesdex-cms/src/lib.rs | 1 + .../http => presentation}/dto.rs | 0 crates/zesdex-cms/src/presentation/error.rs | 48 ++++++ .../http => presentation}/handlers.rs | 148 +++++++---------- crates/zesdex-cms/src/presentation/mod.rs | 31 ++++ crates/zesdex-iam/src/domain/commands.rs | 30 ++++ crates/zesdex-iam/src/domain/mod.rs | 1 + .../zesdex-iam/src/infrastructure/http/mod.rs | 9 -- crates/zesdex-iam/src/infrastructure/mod.rs | 2 - crates/zesdex-iam/src/lib.rs | 1 + .../http => presentation}/dto.rs | 0 crates/zesdex-iam/src/presentation/error.rs | 52 ++++++ .../http => presentation}/handlers.rs | 26 +-- crates/zesdex-iam/src/presentation/mod.rs | 32 ++++ crates/zesdex-middleware/src/auth.rs | 28 ++-- crates/zesdex-middleware/src/cors.rs | 6 - crates/zesdex-utils/src/error.rs | 3 +- 22 files changed, 465 insertions(+), 175 deletions(-) create mode 100644 crates/zesdex-cms/src/domain/commands.rs delete mode 100644 crates/zesdex-cms/src/infrastructure/http/mod.rs rename crates/zesdex-cms/src/{infrastructure/http => presentation}/dto.rs (100%) create mode 100644 crates/zesdex-cms/src/presentation/error.rs rename crates/zesdex-cms/src/{infrastructure/http => presentation}/handlers.rs (52%) create mode 100644 crates/zesdex-cms/src/presentation/mod.rs create mode 100644 crates/zesdex-iam/src/domain/commands.rs delete mode 100644 crates/zesdex-iam/src/infrastructure/http/mod.rs rename crates/zesdex-iam/src/{infrastructure/http => presentation}/dto.rs (100%) create mode 100644 crates/zesdex-iam/src/presentation/error.rs rename crates/zesdex-iam/src/{infrastructure/http => presentation}/handlers.rs (77%) create mode 100644 crates/zesdex-iam/src/presentation/mod.rs diff --git a/crates/zesdex-backend/src/app/runtime/actions/turn.rs b/crates/zesdex-backend/src/app/runtime/actions/turn.rs index aa1f6bb..341d0af 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/turn.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/turn.rs @@ -139,7 +139,7 @@ pub(super) fn run_agent_turn( "[hive-mind] the Hive stirs — Core Intelligence compiling a cognitive cycle plan" ); - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "pipeline".to_string(), message: HIVE_MIND_KICKOFF_NOTE.to_string(), }); @@ -206,7 +206,7 @@ pub(super) fn run_agent_turn( let response_chars = reply.content.as_deref().map_or(0, str::len); tok_out = ((response_chars / 4).max(1)).cast_or(1u64); } - push_event(&events_q, TurnEvent::Usage { + push_event(events_q, TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out, }); @@ -236,7 +236,7 @@ pub(super) fn run_agent_turn( .collect::>() .join(", "); - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "pipeline".to_string(), message: format!( "The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...", @@ -279,13 +279,13 @@ pub(super) fn run_agent_turn( archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); msgs.push(pipeline_msg); - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "pipeline".to_string(), message: "The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..." .to_string(), }); - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "hive_mind_converged".to_string(), message: String::new(), }); @@ -308,7 +308,7 @@ pub(super) fn run_agent_turn( // phase, which previously ran unchecked for minutes at a time. if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) { - push_event(&events_q, TurnEvent::Error("Generation aborted by user".to_string())); + push_event(events_q, TurnEvent::Error("Generation aborted by user".to_string())); return Ok(()); } @@ -348,7 +348,7 @@ pub(super) fn run_agent_turn( // Dispatch the compacted messages to the main thread so the local session history // is permanently compacted and doesn't trigger shaping again immediately on next turn. - push_event(&events_q, TurnEvent::Compacted(compacted.clone())); + push_event(events_q, TurnEvent::Compacted(compacted.clone())); // Also update our local `msgs` variable so the rest of the loop operates on the compacted version msgs.clone_from(&compacted); @@ -417,7 +417,7 @@ pub(super) fn run_agent_turn( ); if reasoning_started && !reasoning_ended { - push_event(&events_q, TurnEvent::StreamToken( + push_event(events_q, TurnEvent::StreamToken( "\n\n\n".to_string(), )); } @@ -429,7 +429,7 @@ pub(super) fn run_agent_turn( if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) || e.to_string().contains("aborted") { - push_event(&events_q, TurnEvent::Error( + push_event(events_q, TurnEvent::Error( "Generation aborted by user".to_string(), )); return Ok(()); @@ -458,7 +458,7 @@ pub(super) fn run_agent_turn( Edit todo.md manually or ask me to focus on specific items.", ); } - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "task_retry".to_string(), message: format!( "Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})" @@ -484,7 +484,7 @@ pub(super) fn run_agent_turn( let response_chars = response.content.as_deref().map_or(0, str::len); tok_out = ((response_chars / 4).max(1)).cast_or(1u64); } - push_event(&events_q, TurnEvent::Usage { + push_event(events_q, TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out, }); @@ -558,7 +558,7 @@ pub(super) fn run_agent_turn( for (tool_call, tool_name, args, output, is_error, is_edit) in results_vec { if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) { - push_event(&events_q, TurnEvent::Error( + push_event(events_q, TurnEvent::Error( "Turn aborted by user".to_string(), )); return Ok(()); @@ -629,7 +629,7 @@ pub(super) fn run_agent_turn( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); - push_event(&events_q, TurnEvent::ToolResult { + push_event(events_q, TurnEvent::ToolResult { tool_call_id: tool_call.id.clone(), tool_name: tool_name.clone(), output: output.clone(), @@ -646,9 +646,9 @@ pub(super) fn run_agent_turn( if !content.is_empty() { archive_message(tc.db.as_ref(), &tc.session_id, &response); if stream_started { - push_event(&events_q, TurnEvent::StreamDone(response.clone())); + push_event(events_q, TurnEvent::StreamDone(response.clone())); } else { - push_event(&events_q, TurnEvent::AssistantMessage(response.clone())); + push_event(events_q, TurnEvent::AssistantMessage(response.clone())); } } @@ -666,7 +666,7 @@ pub(super) fn run_agent_turn( if has_unfinished { todo_retry_count += 1; if todo_retry_count > MAX_TODO_RETRIES { - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "task_retry".to_string(), message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."), }); @@ -677,7 +677,7 @@ pub(super) fn run_agent_turn( let msg = ChatMessage::system(sys_text); archive_message(tc.db.as_ref(), &tc.session_id, &msg); msgs.push(msg); - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "task_retry".to_string(), message: sys_text_clone, }); @@ -701,7 +701,7 @@ pub(super) fn run_agent_turn( if let Some((total_edits_this_turn, prev_edits, el)) = &total_edits_this_turn { if *total_edits_this_turn > 0 { - push_event(&events_q, TurnEvent::SystemNote { + push_event(events_q, TurnEvent::SystemNote { kind: "edits".to_string(), message: total_edits_this_turn.to_string(), }); @@ -738,7 +738,7 @@ pub(super) fn run_agent_turn( total_edits_this_turn.as_ref().map_or(0, |(c, _, _)| *c), ); - push_event(&events_q, TurnEvent::Done); + push_event(events_q, TurnEvent::Done); Ok(()) } diff --git a/crates/zesdex-cms/src/domain/commands.rs b/crates/zesdex-cms/src/domain/commands.rs new file mode 100644 index 0000000..321ebb6 --- /dev/null +++ b/crates/zesdex-cms/src/domain/commands.rs @@ -0,0 +1,153 @@ +//! Command types for CMS domain operations. +//! +//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture, +//! these types encapsulate the input data for create/update operations +//! on domain entities. They decouple presentation DTOs from the entity +//! mutation surface and provide a clear boundary for validation. + +use std::collections::HashMap; + +use super::settings::InternetMode; + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +/// Partial update command for `Settings`. +/// +/// Every field is `Option`al — only non-`None` fields are applied to the +/// existing settings instance. Use `apply_to()` to merge into a `Settings` +/// value. +#[derive(Debug, Clone, Default)] +pub struct SettingsPatch { + /// Override the internet access mode. + pub internet_mode: Option, + /// Override the active provider name. + pub provider: Option, + /// Override the active model name. + pub model: Option, + /// Replace the entire API-keys map. + pub api_keys: Option>, + /// Override the max tokens for completions. + pub max_tokens: Option>, + /// Override the temperature for completions. + pub temperature: Option>, + /// Override the review max lessons per run. + pub review_max_lessons_per_run: Option, + /// Override the adaptive review max skip count. + pub adaptive_review_max_skip: Option, + /// Override the verify shell command. + pub verify_command: Option>, + /// Override the verify timeout in milliseconds. + pub verify_timeout_ms: Option, + /// Override the max concurrency for workflow execution. + pub workflow_max_concurrency: Option, + /// Override the review-enabled flag. + pub review_enabled: Option, + /// Override the session-archive-enabled flag. + pub session_archive_enabled: Option, + /// Override the LSP auto-provision flag. + pub lsp_auto_provision: Option, + /// Override the list of LSP-managed languages. + pub lsp_languages: Option>, + /// Override the hive-mind node timeout in milliseconds. + pub hive_mind_node_timeout_ms: Option, +} + +impl SettingsPatch { + /// Merge this patch into `settings`, overwriting each non-`None` field. + /// + /// Flow: for each optional field, if `Some`, assign it to the target. + /// + /// ## Errors + /// Returns `Err` with a message if `internet_mode` is set to an + /// unrecognised value. + pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> { + if let Some(ref val) = self.internet_mode { + settings.internet_mode = match val.as_str() { + "Off" => InternetMode::Off, + "ReadOnly" => InternetMode::ReadOnly, + "Full" => InternetMode::Full, + _ => return Err(format!("invalid internet_mode '{val}'; expected Off, ReadOnly, or Full")), + }; + } + if let Some(ref val) = self.provider { + settings.provider = val.clone(); + } + if let Some(ref val) = self.model { + settings.model = val.clone(); + } + if let Some(ref val) = self.api_keys { + settings.api_keys = val.clone(); + } + if let Some(val) = self.max_tokens { + settings.max_tokens = val; + } + if let Some(val) = self.temperature { + settings.temperature = val; + } + if let Some(val) = self.review_max_lessons_per_run { + settings.review_max_lessons_per_run = val; + } + if let Some(val) = self.adaptive_review_max_skip { + settings.adaptive_review_max_skip = val; + } + if let Some(ref val) = self.verify_command { + settings.verify_command = val.clone(); + } + if let Some(val) = self.verify_timeout_ms { + settings.verify_timeout_ms = val; + } + if let Some(val) = self.workflow_max_concurrency { + settings.workflow_max_concurrency = val; + } + if let Some(val) = self.review_enabled { + settings.flags.review_enabled = val; + } + if let Some(val) = self.session_archive_enabled { + settings.flags.session_archive_enabled = val; + } + if let Some(val) = self.lsp_auto_provision { + settings.flags.lsp_auto_provision = val; + } + if let Some(ref val) = self.lsp_languages { + settings.lsp_languages = val.clone(); + } + if let Some(val) = self.hive_mind_node_timeout_ms { + settings.hive_mind_node_timeout_ms = val; + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Memory +// --------------------------------------------------------------------------- + +/// Command to create a new memory entry. +/// +/// All required fields are non-optional; optional fields use `Option` +/// and default to sensible values (empty or the service default). +#[derive(Debug, Clone)] +pub struct NewMemory { + /// Unique name / slug for the memory. + pub name: String, + /// One-line summary of what the memory captures. + pub description: String, + /// The full memory content. + pub content: String, + /// Category kind (defaults to "reference" in the handler). + pub kind: Option, + /// Outcome of the remembered action. + pub outcome: Option, + /// Lifecycle stage (defaults to "new" in the handler). + pub lifecycle: Option, + /// Scope context for the memory. + pub scope: Option, + /// Code snippet captured before the action. + pub before_snippet: Option, + /// Code snippet captured after the action. + pub after_snippet: Option, + /// Source provenances (files, conversations, etc.). + pub provenances: Option>, +} diff --git a/crates/zesdex-cms/src/domain/mod.rs b/crates/zesdex-cms/src/domain/mod.rs index 7a9eaf5..85e013e 100644 --- a/crates/zesdex-cms/src/domain/mod.rs +++ b/crates/zesdex-cms/src/domain/mod.rs @@ -19,6 +19,7 @@ //! They contain no I/O, no framework imports, and no side effects. pub mod app_config; +pub mod commands; pub mod conversation; pub mod edit_log; pub mod error; diff --git a/crates/zesdex-cms/src/infrastructure/http/mod.rs b/crates/zesdex-cms/src/infrastructure/http/mod.rs deleted file mode 100644 index 8b4be52..0000000 --- a/crates/zesdex-cms/src/infrastructure/http/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! HTTP adapter — handler functions and DTOs for the CMS REST API. -//! -//! Provides hyper-based request handlers and serialisation types for -//! the CMS HTTP endpoints. Handlers receive domain service trait objects -//! via dependency injection (Arc-wrapped trait objects) and translate -//! between HTTP request/response formats and domain types. -//! -//! ## Sub-modules -//! - `dto` — request/response DTO types (JSON serialisation) -//! - `handlers` — hyper request handler functions -//! -//! ## Endpoints -//! - `GET /settings` — load current application settings -//! - `PUT /settings` — update application settings -//! - `GET /memories` — list all memory slugs -//! - `POST /memories` — create a new memory entry -//! - `POST /memories/{name}` — (future) update memory - -pub mod dto; -pub mod handlers; - -pub use dto::{ - ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse, - SettingsUpdateRequest, -}; -pub use handlers::{ - handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings, -}; diff --git a/crates/zesdex-cms/src/infrastructure/mod.rs b/crates/zesdex-cms/src/infrastructure/mod.rs index e4b5740..75a5504 100644 --- a/crates/zesdex-cms/src/infrastructure/mod.rs +++ b/crates/zesdex-cms/src/infrastructure/mod.rs @@ -6,7 +6,5 @@ //! //! ## Sub-modules //! - `persistence` — file-based repository implementations (JSON, markdown, SQLite) -//! - `http` — hyper-based HTTP API handlers and DTO types -pub mod http; pub mod persistence; diff --git a/crates/zesdex-cms/src/lib.rs b/crates/zesdex-cms/src/lib.rs index 8159a48..f4f48a9 100644 --- a/crates/zesdex-cms/src/lib.rs +++ b/crates/zesdex-cms/src/lib.rs @@ -23,3 +23,4 @@ pub mod application; pub mod domain; pub mod infrastructure; +pub mod presentation; diff --git a/crates/zesdex-cms/src/infrastructure/http/dto.rs b/crates/zesdex-cms/src/presentation/dto.rs similarity index 100% rename from crates/zesdex-cms/src/infrastructure/http/dto.rs rename to crates/zesdex-cms/src/presentation/dto.rs diff --git a/crates/zesdex-cms/src/presentation/error.rs b/crates/zesdex-cms/src/presentation/error.rs new file mode 100644 index 0000000..0abc23c --- /dev/null +++ b/crates/zesdex-cms/src/presentation/error.rs @@ -0,0 +1,48 @@ +//! Typed presentation-layer error type for the CMS crate. +//! +//! `AppError` replaces bare `anyhow::Result` in handler signatures with a +//! structured enum that callers can match on for status-code selection +//! and structured error responses. +//! +//! `From` auto-converts domain errors so handler code uses +//! the `?` operator throughout. +//! +//! # Variants +//! +//! - `BadRequest` — invalid input, validation failure +//! - `NotFound` — resource not found +//! - `Conflict` — resource already exists +//! - `Internal` — unexpected errors translated to a generic message + +use crate::domain::error::ServiceError; + +/// Typed presentation-layer error. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// The request was malformed or contained invalid data. + #[error("Bad request: {0}")] + BadRequest(String), + /// The requested resource was not found. + #[error("Not found: {0}")] + NotFound(String), + /// The request conflicts with the current state. + #[error("Conflict: {0}")] + Conflict(String), + /// An unexpected internal error occurred. + #[error("Internal error: {0}")] + Internal(String), +} + +impl From for AppError { + fn from(e: ServiceError) -> Self { + match e { + ServiceError::Repository(repo_err) => match repo_err { + zesdex_utils::Error::NotFound(msg) => AppError::NotFound(msg), + zesdex_utils::Error::Conflict(msg) => AppError::Conflict(msg), + _ => AppError::Internal(repo_err.to_string()), + }, + ServiceError::InvalidInput(msg) => AppError::BadRequest(msg), + ServiceError::Other(msg) => AppError::Internal(msg), + } + } +} diff --git a/crates/zesdex-cms/src/infrastructure/http/handlers.rs b/crates/zesdex-cms/src/presentation/handlers.rs similarity index 52% rename from crates/zesdex-cms/src/infrastructure/http/handlers.rs rename to crates/zesdex-cms/src/presentation/handlers.rs index dc0e03d..d100d95 100644 --- a/crates/zesdex-cms/src/infrastructure/http/handlers.rs +++ b/crates/zesdex-cms/src/presentation/handlers.rs @@ -17,14 +17,15 @@ //! The caller (e.g. a hyper `Service`) is responsible for serialising the //! response and setting HTTP status codes. -use anyhow::{Context, Result}; use tracing::instrument; +use crate::domain::commands::{NewMemory, SettingsPatch}; use crate::domain::memory::Memory; use crate::domain::service::{MemoryService, SettingsService}; use crate::domain::settings::Settings; use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest}; +use super::error::AppError; /// Handle `GET /settings` /// @@ -32,8 +33,8 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings /// /// Flow: load settings from service → convert to DTO → return. #[instrument(skip(service))] -pub fn handle_get_settings(service: &S) -> Result { - let settings = service.load_settings().context("failed to load settings")?; +pub fn handle_get_settings(service: &S) -> Result { + let settings = service.load_settings()?; Ok(SettingsResponse::from(settings)) } @@ -42,83 +43,44 @@ pub fn handle_get_settings(service: &S) -> Result( service: &S, req: SettingsUpdateRequest, -) -> Result { +) -> Result { + // Build the domain patch command from the wire DTO + let patch = SettingsPatch { + internet_mode: req.internet_mode, + provider: req.provider, + model: req.model, + api_keys: req.api_keys, + max_tokens: req.max_tokens, + temperature: req.temperature, + review_max_lessons_per_run: req.review_max_lessons_per_run, + adaptive_review_max_skip: req.adaptive_review_max_skip, + verify_command: req.verify_command, + verify_timeout_ms: req.verify_timeout_ms, + workflow_max_concurrency: req.workflow_max_concurrency, + review_enabled: req.review_enabled, + session_archive_enabled: req.session_archive_enabled, + lsp_auto_provision: req.lsp_auto_provision, + lsp_languages: req.lsp_languages, + hive_mind_node_timeout_ms: req.hive_mind_node_timeout_ms, + }; + // Load current settings as baseline for partial update - let mut settings: Settings = service - .load_settings() - .context("failed to load current settings for update")?; + let mut settings: Settings = service.load_settings()?; - // Apply each optional field from the request (None = skip, Some = overwrite) - if let Some(val) = req.internet_mode { - settings.internet_mode = match val.as_str() { - "Off" => crate::domain::settings::InternetMode::Off, - "ReadOnly" => crate::domain::settings::InternetMode::ReadOnly, - "Full" => crate::domain::settings::InternetMode::Full, - _ => { - return Err(anyhow::anyhow!( - "invalid internet_mode '{}'; expected Off, ReadOnly, or Full", - val - )); - } - }; - } - if let Some(val) = req.provider { - settings.provider = val; - } - if let Some(val) = req.model { - settings.model = val; - } - if let Some(val) = req.api_keys { - settings.api_keys = val; - } - if let Some(val) = req.max_tokens { - settings.max_tokens = val; - } - if let Some(val) = req.temperature { - settings.temperature = val; - } - if let Some(val) = req.review_max_lessons_per_run { - settings.review_max_lessons_per_run = val; - } - if let Some(val) = req.adaptive_review_max_skip { - settings.adaptive_review_max_skip = val; - } - if let Some(val) = req.verify_command { - settings.verify_command = val; - } - if let Some(val) = req.verify_timeout_ms { - settings.verify_timeout_ms = val; - } - if let Some(val) = req.workflow_max_concurrency { - settings.workflow_max_concurrency = val; - } - if let Some(val) = req.review_enabled { - settings.flags.review_enabled = val; - } - if let Some(val) = req.session_archive_enabled { - settings.flags.session_archive_enabled = val; - } - if let Some(val) = req.lsp_auto_provision { - settings.flags.lsp_auto_provision = val; - } - if let Some(val) = req.lsp_languages { - settings.lsp_languages = val; - } - if let Some(val) = req.hive_mind_node_timeout_ms { - settings.hive_mind_node_timeout_ms = val; - } + // Apply the patch via the domain command + patch + .apply_to(&mut settings) + .map_err(AppError::BadRequest)?; - service - .save_settings(&settings) - .context("failed to save updated settings")?; + service.save_settings(&settings)?; Ok(SettingsResponse::from(settings)) } @@ -131,12 +93,12 @@ pub fn handle_update_settings( /// /// Flow: list slugs from service → map each to minimal MemoryResponse → return. #[instrument(skip(service))] -pub fn handle_list_memories(service: &M) -> Result> { - let slugs = service.list_memories().context("failed to list memories")?; +pub fn handle_list_memories( + service: &M, +) -> Result, AppError> { + let slugs = service.list_memories()?; - // We can't load individual memories without a load_memory method on the - // service. For now, list returns summary info; callers who need full - // content use a separate endpoint. Return minimal responses keyed by slug. + // Return minimal responses keyed by slug. let responses: Vec = slugs .into_iter() .map(|slug| MemoryResponse { @@ -170,26 +132,38 @@ pub fn handle_list_memories(service: &M) -> Result( service: &M, req: MemoryCreateRequest, -) -> Result { - let now = chrono::Utc::now().timestamp(); - let memory = Memory { +) -> Result { + // Build the domain command from the wire DTO + let cmd = NewMemory { name: req.name, description: req.description, content: req.content, - kind: req.kind.unwrap_or_else(|| "reference".to_string()), - created_at: now, - updated_at: now, + kind: req.kind, outcome: req.outcome, - lifecycle: req.lifecycle.unwrap_or_else(|| "new".to_string()), + lifecycle: req.lifecycle, scope: req.scope, before_snippet: req.before_snippet, after_snippet: req.after_snippet, - provenances: req.provenances.unwrap_or_default(), + provenances: req.provenances, }; - service - .save_memory(&memory) - .context("failed to save memory")?; + let now = chrono::Utc::now().timestamp(); + let memory = Memory { + name: cmd.name, + description: cmd.description, + content: cmd.content, + kind: cmd.kind.unwrap_or_else(|| "reference".to_string()), + created_at: now, + updated_at: now, + outcome: cmd.outcome, + lifecycle: cmd.lifecycle.unwrap_or_else(|| "new".to_string()), + scope: cmd.scope, + before_snippet: cmd.before_snippet, + after_snippet: cmd.after_snippet, + provenances: cmd.provenances.unwrap_or_default(), + }; + + service.save_memory(&memory)?; Ok(MemoryResponse::from(memory)) } diff --git a/crates/zesdex-cms/src/presentation/mod.rs b/crates/zesdex-cms/src/presentation/mod.rs new file mode 100644 index 0000000..0ae7522 --- /dev/null +++ b/crates/zesdex-cms/src/presentation/mod.rs @@ -0,0 +1,31 @@ +//! HTTP presentation layer — handler functions and DTOs for the CMS crate. +//! +//! This is the outermost ring of the Clean Architecture onion. Handlers receive +//! domain service trait references via generics and translate between +//! request/response DTOs and domain types. They have **no dependency** on +//! any HTTP framework — callers (IPC server, Axum, etc.) are responsible for +//! mapping results into actual HTTP responses. +//! +//! # Sub-modules +//! +//! - [`dto`] — request/response DTO types (JSON serialisation) +//! - [`handlers`] — handler functions that accept service trait refs + DTOs +//! - [`error`] — typed presentation-layer error type +//! +//! # Dependency rule +//! +//! presentation → application → domain +//! presentation may also depend on infrastructure for wiring/composition. + +pub mod dto; +pub mod error; +pub mod handlers; + +pub use dto::{ + ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse, + SettingsUpdateRequest, +}; +pub use error::AppError; +pub use handlers::{ + handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings, +}; diff --git a/crates/zesdex-iam/src/domain/commands.rs b/crates/zesdex-iam/src/domain/commands.rs new file mode 100644 index 0000000..17f0fbd --- /dev/null +++ b/crates/zesdex-iam/src/domain/commands.rs @@ -0,0 +1,30 @@ +//! Command types for IAM domain operations. +//! +//! Following the `NewXxx` / command pattern from clean architecture, +//! these types encapsulate the input data for create/update operations +//! on domain entities. They decouple presentation DTOs from the entity +//! mutation surface and provide a clear boundary for validation. + +/// Command to create a new session. +/// +/// Carries only the data needed to construct a session entity — the +/// service generates the UUID and timestamp internally. +#[derive(Debug, Clone)] +pub struct NewSession { + /// Human-readable session title. + pub title: String, +} + +impl From for NewSession { + fn from(title: String) -> Self { + Self { title } + } +} + +impl From<&str> for NewSession { + fn from(title: &str) -> Self { + Self { + title: title.to_string(), + } + } +} diff --git a/crates/zesdex-iam/src/domain/mod.rs b/crates/zesdex-iam/src/domain/mod.rs index cfbd285..63b33cd 100644 --- a/crates/zesdex-iam/src/domain/mod.rs +++ b/crates/zesdex-iam/src/domain/mod.rs @@ -11,6 +11,7 @@ //! - [`service`] — Trait definitions: `OAuthService`, `SessionService` //! - [`session`] — `Session` entity (IAM wrapper around `zesdex_entities::Session`) +pub mod commands; pub mod error; pub mod oauth; pub mod repository; diff --git a/crates/zesdex-iam/src/infrastructure/http/mod.rs b/crates/zesdex-iam/src/infrastructure/http/mod.rs deleted file mode 100644 index 65664e3..0000000 --- a/crates/zesdex-iam/src/infrastructure/http/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! HTTP adapter layer for OAuth callback handling. -//! -//! # Sub-modules -//! -//! - [`dto`] — Request/response DTOs for the loopback endpoint -//! - [`handlers`] — HTTP handler that validates state and extracts `?code=` - -pub mod dto; -pub mod handlers; diff --git a/crates/zesdex-iam/src/infrastructure/mod.rs b/crates/zesdex-iam/src/infrastructure/mod.rs index eb6ea13..f66c5fb 100644 --- a/crates/zesdex-iam/src/infrastructure/mod.rs +++ b/crates/zesdex-iam/src/infrastructure/mod.rs @@ -5,12 +5,10 @@ //! //! # Sub-modules //! -//! - [`http`] — HTTP server, DTOs, handlers for the OAuth callback //! - [`oauth_loopback`] — Loopback HTTP server to receive the OAuth redirect //! - [`persistence`] — Filesystem-backed repositories (JSON + PID locks) //! - [`rng`] — System random token / UUID generation -pub mod http; pub mod oauth_loopback; pub mod persistence; pub mod rng; diff --git a/crates/zesdex-iam/src/lib.rs b/crates/zesdex-iam/src/lib.rs index e0be993..5b307e0 100644 --- a/crates/zesdex-iam/src/lib.rs +++ b/crates/zesdex-iam/src/lib.rs @@ -12,3 +12,4 @@ pub mod application; pub mod domain; pub mod infrastructure; +pub mod presentation; diff --git a/crates/zesdex-iam/src/infrastructure/http/dto.rs b/crates/zesdex-iam/src/presentation/dto.rs similarity index 100% rename from crates/zesdex-iam/src/infrastructure/http/dto.rs rename to crates/zesdex-iam/src/presentation/dto.rs diff --git a/crates/zesdex-iam/src/presentation/error.rs b/crates/zesdex-iam/src/presentation/error.rs new file mode 100644 index 0000000..d65d72d --- /dev/null +++ b/crates/zesdex-iam/src/presentation/error.rs @@ -0,0 +1,52 @@ +//! Typed presentation-layer error type for the IAM crate. +//! +//! `AppError` replaces bare `anyhow::Result` in handler signatures with a +//! structured enum that callers can match on for status-code selection +//! and structured error responses. +//! +//! `From` auto-converts domain errors so handler code uses +//! the `?` operator throughout. +//! +//! # Variants +//! +//! - `BadRequest` — invalid input, validation failure, OAuth state mismatch +//! - `NotFound` — resource (session, token) not found +//! - `Conflict` — resource already exists (e.g. duplicate session) +//! - `Internal` — unexpected errors translated to a generic message + +use crate::domain::error::ServiceError; + +/// Typed presentation-layer error. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// The request was malformed or contained invalid data. + #[error("Bad request: {0}")] + BadRequest(String), + /// The requested resource was not found. + #[error("Not found: {0}")] + NotFound(String), + /// The request conflicts with the current state. + #[error("Conflict: {0}")] + Conflict(String), + /// An unexpected internal error occurred. + #[error("Internal error: {0}")] + Internal(String), +} + +impl From for AppError { + fn from(e: ServiceError) -> Self { + match e { + ServiceError::Repository(repo_err) => match repo_err { + zesdex_utils::Error::NotFound(msg) => AppError::NotFound(msg), + zesdex_utils::Error::Conflict(msg) => AppError::Conflict(msg), + _ => AppError::Internal(repo_err.to_string()), + }, + ServiceError::InvalidConfig(msg) => AppError::BadRequest(msg), + ServiceError::StateMismatch => { + AppError::BadRequest("OAuth state mismatch — possible CSRF attack".into()) + } + ServiceError::OAuthProvider(msg) => AppError::Internal(msg), + ServiceError::Other(msg) => AppError::Internal(msg), + } + } +} diff --git a/crates/zesdex-iam/src/infrastructure/http/handlers.rs b/crates/zesdex-iam/src/presentation/handlers.rs similarity index 77% rename from crates/zesdex-iam/src/infrastructure/http/handlers.rs rename to crates/zesdex-iam/src/presentation/handlers.rs index d31d582..5932836 100644 --- a/crates/zesdex-iam/src/infrastructure/http/handlers.rs +++ b/crates/zesdex-iam/src/presentation/handlers.rs @@ -19,24 +19,27 @@ use tracing::instrument; use zesdex_entities::domain::auth::SessionId; use crate::domain::service::{OAuthService, SessionService}; -use crate::infrastructure::http::dto::{ +use crate::presentation::dto::{ CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse, OAuthTokenResponse, SessionListResponse, SessionResponse, }; +use crate::presentation::error::AppError; /// Handle a create-session request. #[instrument(skip(service), fields(title = %req.title))] pub fn handle_create_session( service: &S, req: CreateSessionRequest, -) -> anyhow::Result { +) -> Result { let session = service.create_session(&req.title)?; Ok(SessionResponse { session }) } /// Handle a list-sessions request. #[instrument(skip(service))] -pub fn handle_list_sessions(service: &S) -> anyhow::Result { +pub fn handle_list_sessions( + service: &S, +) -> Result { let sessions = service.list_all()?; let total = sessions.len(); Ok(SessionListResponse { sessions, total }) @@ -44,9 +47,12 @@ pub fn handle_list_sessions(service: &S) -> anyhow::Result(service: &S, id: &str) -> anyhow::Result<()> { +pub fn handle_archive_session( + service: &S, + id: &str, +) -> Result<(), AppError> { let sid = SessionId::new(id) - .map_err(|e| anyhow::anyhow!("invalid session id: {e}"))?; + .map_err(|e| AppError::BadRequest(format!("invalid session id: {e}")))?; service.archive_session(sid)?; Ok(()) } @@ -56,7 +62,7 @@ pub fn handle_archive_session(service: &S, id: &str) -> anyho pub fn handle_start_oauth( service: &O, req: OAuthStartRequest, -) -> anyhow::Result { +) -> Result { let (auth_url, state) = service.start_flow(&req.config, &req.redirect_uri)?; Ok(OAuthStartResponse { auth_url, state }) } @@ -66,16 +72,18 @@ pub fn handle_start_oauth( pub fn handle_complete_oauth( service: &O, req: OAuthCompleteRequest, -) -> anyhow::Result { +) -> Result { let token = service.complete_flow(&req.config, &req.redirect_uri, &req.code, &req.state)?; Ok(OAuthTokenResponse { token }) } /// Handle a get-token request. #[instrument(skip(service))] -pub fn handle_get_token(service: &O) -> anyhow::Result { +pub fn handle_get_token( + service: &O, +) -> Result { let token = service .get_token()? - .ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?; + .ok_or_else(|| AppError::NotFound("no OAuth token stored".into()))?; Ok(OAuthTokenResponse { token }) } diff --git a/crates/zesdex-iam/src/presentation/mod.rs b/crates/zesdex-iam/src/presentation/mod.rs new file mode 100644 index 0000000..c893acc --- /dev/null +++ b/crates/zesdex-iam/src/presentation/mod.rs @@ -0,0 +1,32 @@ +//! HTTP presentation layer — handler functions and DTOs for the IAM crate. +//! +//! This is the outermost ring of the Clean Architecture onion. Handlers receive +//! domain service trait references via generics and translate between +//! request/response DTOs and domain types. They have **no dependency** on +//! any HTTP framework — callers (IPC server, Axum, etc.) are responsible for +//! mapping results into actual HTTP responses. +//! +//! # Sub-modules +//! +//! - [`dto`] — request/response DTO types (JSON serialisation) +//! - [`handlers`] — handler functions that accept service trait refs + DTOs +//! - [`error`] — typed presentation-layer error type +//! +//! # Dependency rule +//! +//! presentation → application → domain +//! presentation may also depend on infrastructure for wiring/composition. + +pub mod dto; +pub mod error; +pub mod handlers; + +pub use dto::{ + CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse, + OAuthTokenResponse, SessionListResponse, SessionResponse, +}; +pub use error::AppError; +pub use handlers::{ + handle_archive_session, handle_complete_oauth, handle_create_session, handle_get_token, + handle_list_sessions, handle_start_oauth, +}; diff --git a/crates/zesdex-middleware/src/auth.rs b/crates/zesdex-middleware/src/auth.rs index 32de5a5..2606f7d 100644 --- a/crates/zesdex-middleware/src/auth.rs +++ b/crates/zesdex-middleware/src/auth.rs @@ -122,7 +122,7 @@ pub struct SessionAuthMiddleware { /// Extract and validate `X-Session-Id` from request headers. /// /// Flow: read header -> validate non-empty -> return ID or a 401 error response. -fn extract_session_id(req: &Request) -> Result { +fn extract_session_id(req: &Request) -> Result> { let session_id = req .headers() .get("X-Session-Id") // custom header carrying the session identifier @@ -131,7 +131,9 @@ fn extract_session_id(req: &Request) -> Result Ok(id), - _ => Err((StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response()), + _ => Err(Box::new( + (StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response(), + )), } } @@ -142,7 +144,7 @@ fn validate_and_build_identity( session_id: &str, store: &Store, req: &Request, -) -> Result { +) -> Result> { match validate_session(session_id, store) { Ok(_session) => { let user_agent = req @@ -153,11 +155,13 @@ fn validate_and_build_identity( .to_string(); Ok(SessionIdentity::new(session_id.to_string(), user_agent)) } - Err(e) => Err(( - StatusCode::UNAUTHORIZED, - format!("session validation failed: {e}"), - ) - .into_response()), + Err(e) => Err(Box::new( + ( + StatusCode::UNAUTHORIZED, + format!("session validation failed: {e}"), + ) + .into_response(), + )), } } @@ -181,14 +185,14 @@ where let session_id = match extract_session_id(&req) { Ok(id) => id, - Err(resp) => return Box::pin(async move { Ok(resp) }), + Err(resp) => return Box::pin(async move { Ok(*resp) }), }; match validate_and_build_identity(&session_id, &store, &req) { Ok(identity) => { req.extensions_mut().insert(identity); } - Err(resp) => return Box::pin(async move { Ok(resp) }), + Err(resp) => return Box::pin(async move { Ok(*resp) }), }; let fut = self.inner.call(req); @@ -212,12 +216,12 @@ pub async fn require_session( ) -> Response { let session_id = match extract_session_id(&req) { Ok(id) => id, - Err(resp) => return resp, + Err(resp) => return *resp, }; let identity = match validate_and_build_identity(&session_id, &store, &req) { Ok(identity) => identity, - Err(resp) => return resp, + Err(resp) => return *resp, }; req.extensions_mut().insert(identity); next.run(req).await diff --git a/crates/zesdex-middleware/src/cors.rs b/crates/zesdex-middleware/src/cors.rs index ee3bd63..c1e6452 100644 --- a/crates/zesdex-middleware/src/cors.rs +++ b/crates/zesdex-middleware/src/cors.rs @@ -6,12 +6,6 @@ use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; -/// Return a permissive [`CorsLayer`] for local daemon IPC. -/// -/// - **Origin**: any (`*`) -/// - **Methods**: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS` -/// - **Headers**: `Content-Type`, `Authorization`, `X-Session-Id`, -/// `X-Request-Id`, `User-Agent` /// Return a permissive [`CorsLayer`] for local daemon IPC. /// /// - **Origin**: any (`*`) diff --git a/crates/zesdex-utils/src/error.rs b/crates/zesdex-utils/src/error.rs index 128fa57..11625a5 100644 --- a/crates/zesdex-utils/src/error.rs +++ b/crates/zesdex-utils/src/error.rs @@ -71,7 +71,8 @@ impl Error { /// Convert an `anyhow::Error` to `zesdex_utils::Error` by attempting /// downcast to known inner types. - pub fn from_anyhow(e: anyhow::Error) -> Self { + #[must_use] + pub fn from_anyhow(e: &anyhow::Error) -> Self { if let Some(ioe) = e.downcast_ref::() { return Error::Io(std::io::Error::new(ioe.kind(), ioe.to_string())); }