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.
This commit is contained in:
asepharyana
2026-07-20 06:53:01 +07:00
parent e9a8e93c83
commit 714b4617dd
22 changed files with 465 additions and 175 deletions
@@ -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::<Vec<String>>()
.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</think>\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(())
}
+153
View File
@@ -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<String>,
/// Override the active provider name.
pub provider: Option<String>,
/// Override the active model name.
pub model: Option<String>,
/// Replace the entire API-keys map.
pub api_keys: Option<HashMap<String, String>>,
/// Override the max tokens for completions.
pub max_tokens: Option<Option<u32>>,
/// Override the temperature for completions.
pub temperature: Option<Option<f32>>,
/// Override the review max lessons per run.
pub review_max_lessons_per_run: Option<usize>,
/// Override the adaptive review max skip count.
pub adaptive_review_max_skip: Option<u32>,
/// Override the verify shell command.
pub verify_command: Option<Option<String>>,
/// Override the verify timeout in milliseconds.
pub verify_timeout_ms: Option<u64>,
/// Override the max concurrency for workflow execution.
pub workflow_max_concurrency: Option<usize>,
/// Override the review-enabled flag.
pub review_enabled: Option<bool>,
/// Override the session-archive-enabled flag.
pub session_archive_enabled: Option<bool>,
/// Override the LSP auto-provision flag.
pub lsp_auto_provision: Option<bool>,
/// Override the list of LSP-managed languages.
pub lsp_languages: Option<Vec<String>>,
/// Override the hive-mind node timeout in milliseconds.
pub hive_mind_node_timeout_ms: Option<u64>,
}
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<String>,
/// Outcome of the remembered action.
pub outcome: Option<String>,
/// Lifecycle stage (defaults to "new" in the handler).
pub lifecycle: Option<String>,
/// Scope context for the memory.
pub scope: Option<String>,
/// Code snippet captured before the action.
pub before_snippet: Option<String>,
/// Code snippet captured after the action.
pub after_snippet: Option<String>,
/// Source provenances (files, conversations, etc.).
pub provenances: Option<Vec<String>>,
}
+1
View File
@@ -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;
@@ -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,
};
@@ -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;
+1
View File
@@ -23,3 +23,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub mod presentation;
@@ -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<ServiceError>` 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<ServiceError> 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),
}
}
}
@@ -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<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
let settings = service.load_settings().context("failed to load settings")?;
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse, AppError> {
let settings = service.load_settings()?;
Ok(SettingsResponse::from(settings))
}
@@ -42,83 +43,44 @@ pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsRe
/// Applies a partial update from `req` to the current settings, persists
/// the result, and returns the updated `SettingsResponse`.
///
/// Flow: load current settings → apply each optional field → save → return DTO.
/// Flow: build `SettingsPatch` from DTO → apply to current settings → save → return DTO.
///
/// ## Validation
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
/// - `internet_mode` is validated by `SettingsPatch::apply_to`.
#[instrument(skip(service))]
pub fn handle_update_settings<S: SettingsService>(
service: &S,
req: SettingsUpdateRequest,
) -> Result<SettingsResponse> {
) -> Result<SettingsResponse, AppError> {
// 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<S: SettingsService>(
///
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
#[instrument(skip(service))]
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
let slugs = service.list_memories().context("failed to list memories")?;
pub fn handle_list_memories<M: MemoryService>(
service: &M,
) -> Result<Vec<MemoryResponse>, 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<MemoryResponse> = slugs
.into_iter()
.map(|slug| MemoryResponse {
@@ -170,26 +132,38 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
pub fn handle_create_memory<M: MemoryService>(
service: &M,
req: MemoryCreateRequest,
) -> Result<MemoryResponse> {
let now = chrono::Utc::now().timestamp();
let memory = Memory {
) -> Result<MemoryResponse, AppError> {
// 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))
}
+31
View File
@@ -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,
};
+30
View File
@@ -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<String> for NewSession {
fn from(title: String) -> Self {
Self { title }
}
}
impl From<&str> for NewSession {
fn from(title: &str) -> Self {
Self {
title: title.to_string(),
}
}
}
+1
View File
@@ -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;
@@ -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;
@@ -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;
+1
View File
@@ -12,3 +12,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub mod presentation;
@@ -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<ServiceError>` 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<ServiceError> 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),
}
}
}
@@ -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<S: SessionService>(
service: &S,
req: CreateSessionRequest,
) -> anyhow::Result<SessionResponse> {
) -> Result<SessionResponse, AppError> {
let session = service.create_session(&req.title)?;
Ok(SessionResponse { session })
}
/// Handle a list-sessions request.
#[instrument(skip(service))]
pub fn handle_list_sessions<S: SessionService>(service: &S) -> anyhow::Result<SessionListResponse> {
pub fn handle_list_sessions<S: SessionService>(
service: &S,
) -> Result<SessionListResponse, AppError> {
let sessions = service.list_all()?;
let total = sessions.len();
Ok(SessionListResponse { sessions, total })
@@ -44,9 +47,12 @@ pub fn handle_list_sessions<S: SessionService>(service: &S) -> anyhow::Result<Se
/// Handle an archive-session request.
#[instrument(skip(service), fields(session_id = %id))]
pub fn handle_archive_session<S: SessionService>(service: &S, id: &str) -> anyhow::Result<()> {
pub fn handle_archive_session<S: SessionService>(
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<S: SessionService>(service: &S, id: &str) -> anyho
pub fn handle_start_oauth<O: OAuthService>(
service: &O,
req: OAuthStartRequest,
) -> anyhow::Result<OAuthStartResponse> {
) -> Result<OAuthStartResponse, AppError> {
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<O: OAuthService>(
pub fn handle_complete_oauth<O: OAuthService>(
service: &O,
req: OAuthCompleteRequest,
) -> anyhow::Result<OAuthTokenResponse> {
) -> Result<OAuthTokenResponse, AppError> {
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<O: OAuthService>(service: &O) -> anyhow::Result<OAuthTokenResponse> {
pub fn handle_get_token<O: OAuthService>(
service: &O,
) -> Result<OAuthTokenResponse, AppError> {
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 })
}
+32
View File
@@ -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,
};
+16 -12
View File
@@ -122,7 +122,7 @@ pub struct SessionAuthMiddleware<S> {
/// 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<ReqBody>(req: &Request<ReqBody>) -> Result<String, Response> {
fn extract_session_id<ReqBody>(req: &Request<ReqBody>) -> Result<String, Box<Response>> {
let session_id = req
.headers()
.get("X-Session-Id") // custom header carrying the session identifier
@@ -131,7 +131,9 @@ fn extract_session_id<ReqBody>(req: &Request<ReqBody>) -> Result<String, Respons
match session_id {
Some(id) if !id.is_empty() => 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<ReqBody>(
session_id: &str,
store: &Store,
req: &Request<ReqBody>,
) -> Result<SessionIdentity, Response> {
) -> Result<SessionIdentity, Box<Response>> {
match validate_session(session_id, store) {
Ok(_session) => {
let user_agent = req
@@ -153,11 +155,13 @@ fn validate_and_build_identity<ReqBody>(
.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
-6
View File
@@ -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 (`*`)
+2 -1
View File
@@ -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::<std::io::Error>() {
return Error::Io(std::io::Error::new(ioe.kind(), ioe.to_string()));
}