refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
pub mod oauth_service;
|
||||
pub mod session_service;
|
||||
@@ -0,0 +1,199 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! OAuth flow use-cases.
|
||||
//!
|
||||
//! `OAuthServiceImpl` drives the authorization-code + PKCE flow:
|
||||
//! generating the verifier, building the auth URL, exchanging the code
|
||||
//! for a token, and persisting the result via the injected repository.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::domain::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::domain::repository::OAuthRepository;
|
||||
use crate::domain::service::OAuthService;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE primitives (private to this use-case module)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VERIFIER_LENGTH: usize = 64;
|
||||
|
||||
/// A randomly generated, base64url-encoded PKCE code verifier.
|
||||
struct CodeVerifier(String);
|
||||
|
||||
impl CodeVerifier {
|
||||
fn new() -> Self {
|
||||
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Derive the S256 code challenge (SHA-256 → base64url).
|
||||
fn challenge(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce one pseudo-random byte from the system clock mixed with a
|
||||
/// monotonic counter, providing ~64 bits of per-call unpredictability
|
||||
/// without a full `rand` dependency.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
((seed ^ counter) & 0xFF) as u8
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuthServiceImpl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concrete OAuth service backed by a generic token repository.
|
||||
///
|
||||
/// The code verifier is stored to a sidecar file (`token_path` with
|
||||
/// `.verifier` extension) in `start_flow` and consumed in `complete_flow`.
|
||||
pub struct OAuthServiceImpl<R: OAuthRepository> {
|
||||
pub token_repo: R,
|
||||
pub token_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository> OAuthServiceImpl<R> {
|
||||
/// Create a new OAuth service.
|
||||
///
|
||||
/// * `token_repo` — repository used to persist / load tokens.
|
||||
/// * `token_path` — file path where the token JSON is stored.
|
||||
pub fn new(token_repo: R, token_path: PathBuf) -> Self {
|
||||
OAuthServiceImpl {
|
||||
token_repo,
|
||||
token_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the sidecar file that holds the PKCE verifier between
|
||||
/// `start_flow` and `complete_flow`.
|
||||
fn verifier_path(&self) -> PathBuf {
|
||||
let mut p = self.token_path.clone();
|
||||
let ext = p
|
||||
.extension()
|
||||
.map(|e| format!("{}.verifier", e.to_string_lossy()))
|
||||
.unwrap_or_else(|| "verifier".to_string());
|
||||
p.set_extension(ext);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
fn start_flow(&self, config: &OAuthConfig) -> anyhow::Result<String> {
|
||||
if config.auth_url.is_empty() {
|
||||
anyhow::bail!("OAuth auth_url is empty");
|
||||
}
|
||||
|
||||
let verifier = CodeVerifier::new();
|
||||
let challenge = verifier.challenge();
|
||||
|
||||
// Persist the verifier so complete_flow can retrieve it.
|
||||
if let Some(parent) = self.token_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(self.verifier_path(), verifier.as_str())?;
|
||||
|
||||
// Persist a random state token for CSRF protection.
|
||||
let state = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let mut url = url::Url::parse(&config.auth_url)
|
||||
.map_err(|e| anyhow::anyhow!("invalid auth_url '{}': {e}", config.auth_url))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("redirect_uri", "http://127.0.0.1:0/callback")
|
||||
.append_pair("scope", &config.scopes.join(" "))
|
||||
.append_pair("state", &state)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", &challenge);
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn complete_flow(&self, config: &OAuthConfig, code: &str) -> anyhow::Result<OAuthToken> {
|
||||
// Load the verifier that was stored during start_flow.
|
||||
let verifier_path = self.verifier_path();
|
||||
let verifier = std::fs::read_to_string(&verifier_path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read PKCE verifier: {e}"))?;
|
||||
|
||||
// Exchange the authorization code for a token.
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut params = std::collections::HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("code", code);
|
||||
params.insert("redirect_uri", "http://127.0.0.1:0/callback");
|
||||
params.insert("client_id", &config.client_id);
|
||||
params.insert("code_verifier", &verifier);
|
||||
|
||||
if let Some(ref secret) = config.client_secret {
|
||||
params.insert("client_secret", secret);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&config.token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.map_err(|e| anyhow::anyhow!("token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse token response: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("token endpoint returned {status}: {body}");
|
||||
}
|
||||
|
||||
let access_token = body["access_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("response missing access_token"))?
|
||||
.to_string();
|
||||
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let token = OAuthToken {
|
||||
access_token,
|
||||
refresh_token: body["refresh_token"].as_str().map(String::from),
|
||||
expires_at: now + expires_in,
|
||||
token_type: body["token_type"]
|
||||
.as_str()
|
||||
.unwrap_or("Bearer")
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
// Persist the token and clean up the verifier.
|
||||
self.token_repo.save_token(&self.token_path, &token)?;
|
||||
let _ = std::fs::remove_file(&verifier_path);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>> {
|
||||
self.token_repo.load_token(&self.token_path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Session management use-cases.
|
||||
//!
|
||||
//! `SessionServiceImpl` implements `SessionService` by delegating to
|
||||
//! injected repository implementations, keeping the orchestration logic
|
||||
//! independent of any concrete persistence mechanism.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repository::{SessionLockRepository, SessionRepository};
|
||||
use crate::domain::service::SessionService;
|
||||
use crate::domain::session::Session;
|
||||
|
||||
/// Concrete session service backed by generic repository implementations.
|
||||
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
|
||||
pub session_repo: R,
|
||||
pub lock_repo: L,
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
|
||||
/// Create a new session service with the given repositories and base
|
||||
/// data directory.
|
||||
pub fn new(session_repo: R, lock_repo: L, base_dir: PathBuf) -> Self {
|
||||
SessionServiceImpl {
|
||||
session_repo,
|
||||
lock_repo,
|
||||
base_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionServiceImpl<R, L> {
|
||||
fn create_session(&self, title: &str) -> anyhow::Result<Session> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let title_owned = if title.is_empty() {
|
||||
"New Session".to_string()
|
||||
} else {
|
||||
title.to_string()
|
||||
};
|
||||
let session = Session::new(id, title_owned);
|
||||
self.session_repo.save_session(&self.base_dir, &session)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn list_all(&self) -> anyhow::Result<Vec<Session>> {
|
||||
self.session_repo.list_sessions(&self.base_dir)
|
||||
}
|
||||
|
||||
fn archive_session(&self, id: &str) -> anyhow::Result<()> {
|
||||
let mut session = self.session_repo.load_session(&self.base_dir, id)?;
|
||||
session.archived = true;
|
||||
session.updated_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
self.session_repo.save_session(&self.base_dir, &session)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod oauth;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod session_lock;
|
||||
@@ -0,0 +1,44 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Pure OAuth entities — no HTTP or persistence logic.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An OAuth 2.0 access token with optional refresh token and absolute
|
||||
/// expiry time (epoch seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: u64,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
OAuthConfig {
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: vec![
|
||||
"openid".to_string(),
|
||||
"profile".to_string(),
|
||||
"email".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Repository trait definitions (pure — no impls, no concrete persistence).
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::oauth::OAuthToken;
|
||||
use crate::domain::session::Session;
|
||||
|
||||
/// Repository for loading, saving, listing, and deleting sessions.
|
||||
pub trait SessionRepository {
|
||||
/// List all loadable sessions under `<base_dir>/sessions/`.
|
||||
fn list_sessions(&self, base_dir: &Path) -> anyhow::Result<Vec<Session>>;
|
||||
|
||||
/// Load a single session by id.
|
||||
fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<Session>;
|
||||
|
||||
/// Save a session's metadata to disk.
|
||||
fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()>;
|
||||
|
||||
/// Delete a session directory and all its contents.
|
||||
fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
/// Repository for per-session PID-file advisory locks.
|
||||
pub trait SessionLockRepository {
|
||||
/// Try to acquire the lock for a session directory.
|
||||
/// Returns `true` if the lock was acquired, `false` if another live
|
||||
/// process holds it.
|
||||
fn try_lock(&self, session_dir: &Path) -> anyhow::Result<bool>;
|
||||
|
||||
/// Release the lock by removing the lock file.
|
||||
fn unlock(&self, session_dir: &Path) -> anyhow::Result<()>;
|
||||
|
||||
/// Check whether a process with the given PID is alive.
|
||||
fn is_alive(&self, pid: u32) -> bool;
|
||||
}
|
||||
|
||||
/// Repository for persisting and loading OAuth tokens.
|
||||
pub trait OAuthRepository {
|
||||
/// Persist an OAuth token to a JSON file.
|
||||
fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()>;
|
||||
|
||||
/// Load an OAuth token from a JSON file, returning `None` if the file
|
||||
/// does not exist.
|
||||
fn load_token(&self, path: &Path) -> anyhow::Result<Option<OAuthToken>>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Service trait definitions — use-case interfaces for session management
|
||||
//! and OAuth flows.
|
||||
use crate::domain::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::domain::session::Session;
|
||||
|
||||
/// Session management use-case boundary.
|
||||
pub trait SessionService {
|
||||
/// Create a new session with a generated UUID and the given title.
|
||||
fn create_session(&self, title: &str) -> anyhow::Result<Session>;
|
||||
|
||||
/// List all available sessions.
|
||||
fn list_all(&self) -> anyhow::Result<Vec<Session>>;
|
||||
|
||||
/// Archive a session by id (sets `archived = true`).
|
||||
fn archive_session(&self, id: &str) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
/// OAuth flow use-case boundary.
|
||||
pub trait OAuthService {
|
||||
/// Start an OAuth authorization-code + PKCE flow.
|
||||
/// Returns the provider's authorization URL to visit.
|
||||
fn start_flow(&self, config: &OAuthConfig) -> anyhow::Result<String>;
|
||||
|
||||
/// Complete the OAuth flow by exchanging an authorization code for a
|
||||
/// token.
|
||||
fn complete_flow(&self, config: &OAuthConfig, code: &str) -> anyhow::Result<OAuthToken>;
|
||||
|
||||
/// Retrieve the currently stored OAuth token (if any).
|
||||
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Pure Session entity — no persistence logic.
|
||||
//!
|
||||
//! This type represents the metadata of one conversation session.
|
||||
//! All save / load / list operations belong to the repository traits.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata for one conversation session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub title: String,
|
||||
pub model: String,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub message_count: u32,
|
||||
pub token_count: u32,
|
||||
pub archived: bool,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with default field values.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's conversation.json path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Pure SessionLock entity — no lock / unlock logic.
|
||||
//!
|
||||
//! Lock acquisition and release are handled by the repository.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A PID-file based session lock handle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionLock {
|
||||
pub path: PathBuf,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
/// Construct a lock handle for a session directory (does not acquire
|
||||
/// the lock yet — use the repository's `try_lock`).
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! IAM-specific HTTP / IPC DTOs (Data Transfer Objects).
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::domain::session::Session;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for creating a new session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Response containing one session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionResponse {
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
/// Response containing a list of sessions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionListResponse {
|
||||
pub sessions: Vec<Session>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for starting an OAuth flow.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthStartRequest {
|
||||
pub config: OAuthConfig,
|
||||
}
|
||||
|
||||
/// Response containing the authorization URL for an OAuth flow.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthStartResponse {
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
/// Request body for completing an OAuth flow with an authorization code.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthCompleteRequest {
|
||||
pub config: OAuthConfig,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Response containing the acquired OAuth token.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthTokenResponse {
|
||||
pub token: OAuthToken,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! IPC / HTTP handler functions.
|
||||
//!
|
||||
//! Each handler is a plain function that takes a service reference and a
|
||||
//! request DTO, delegates to the service, and returns a response DTO.
|
||||
//! Handlers are generic over the service trait so they remain independent
|
||||
//! of concrete implementations.
|
||||
|
||||
use crate::domain::service::{OAuthService, SessionService};
|
||||
use crate::infrastructure::http::dto::{
|
||||
CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse,
|
||||
OAuthTokenResponse, SessionListResponse, SessionResponse,
|
||||
};
|
||||
|
||||
/// Handle a create-session request.
|
||||
pub fn handle_create_session<S: SessionService>(
|
||||
service: &S,
|
||||
req: CreateSessionRequest,
|
||||
) -> anyhow::Result<SessionResponse> {
|
||||
let session = service.create_session(&req.title)?;
|
||||
Ok(SessionResponse { session })
|
||||
}
|
||||
|
||||
/// Handle a list-sessions request.
|
||||
pub fn handle_list_sessions<S: SessionService>(
|
||||
service: &S,
|
||||
) -> anyhow::Result<SessionListResponse> {
|
||||
let sessions = service.list_all()?;
|
||||
let total = sessions.len();
|
||||
Ok(SessionListResponse { sessions, total })
|
||||
}
|
||||
|
||||
/// Handle an archive-session request.
|
||||
pub fn handle_archive_session<S: SessionService>(
|
||||
service: &S,
|
||||
id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
service.archive_session(id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a start-OAuth-flow request.
|
||||
pub fn handle_start_oauth<O: OAuthService>(
|
||||
service: &O,
|
||||
req: OAuthStartRequest,
|
||||
) -> anyhow::Result<OAuthStartResponse> {
|
||||
let auth_url = service.start_flow(&req.config)?;
|
||||
Ok(OAuthStartResponse { auth_url })
|
||||
}
|
||||
|
||||
/// Handle a complete-OAuth-flow request.
|
||||
pub fn handle_complete_oauth<O: OAuthService>(
|
||||
service: &O,
|
||||
req: OAuthCompleteRequest,
|
||||
) -> anyhow::Result<OAuthTokenResponse> {
|
||||
let token = service.complete_flow(&req.config, &req.code)?;
|
||||
Ok(OAuthTokenResponse { token })
|
||||
}
|
||||
|
||||
/// Handle a get-token request.
|
||||
pub fn handle_get_token<O: OAuthService>(
|
||||
service: &O,
|
||||
) -> anyhow::Result<OAuthTokenResponse> {
|
||||
let token = service
|
||||
.get_token()?
|
||||
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
|
||||
Ok(OAuthTokenResponse { token })
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod oauth_repo;
|
||||
pub mod session_repo;
|
||||
@@ -0,0 +1,52 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Filesystem-backed `OAuthRepository` implementation.
|
||||
//!
|
||||
//! Tokens are stored as a single JSON file. Writes use a write-then-rename
|
||||
//! + fsync pattern for crash safety.
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::oauth::OAuthToken;
|
||||
use crate::domain::repository::OAuthRepository;
|
||||
|
||||
/// Concrete filesystem OAuth token repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileSystemOAuthRepository;
|
||||
|
||||
impl FileSystemOAuthRepository {
|
||||
/// Create a new filesystem OAuth repository.
|
||||
pub fn new() -> Self {
|
||||
FileSystemOAuthRepository
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthRepository for FileSystemOAuthRepository {
|
||||
fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let data = serde_json::to_string_pretty(token)?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_token(&self, path: &Path) -> anyhow::Result<Option<OAuthToken>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let token: OAuthToken = serde_json::from_str(&data)?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Filesystem-backed `SessionRepository` implementation.
|
||||
//!
|
||||
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
|
||||
//! Writes use a write-then-rename + fsync pattern for crash safety.
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::repository::SessionRepository;
|
||||
use crate::domain::session::Session;
|
||||
|
||||
/// Concrete filesystem session repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileSystemSessionRepository;
|
||||
|
||||
impl FileSystemSessionRepository {
|
||||
/// Create a new filesystem session repository.
|
||||
pub fn new() -> Self {
|
||||
FileSystemSessionRepository
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionRepository for FileSystemSessionRepository {
|
||||
fn list_sessions(&self, base_dir: &Path) -> anyhow::Result<Vec<Session>> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut sessions = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let id = entry.file_name().to_string_lossy().to_string();
|
||||
if let Ok(session) = self.load_session(base_dir, &id) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<Session> {
|
||||
// Directory-traversal prevention.
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
anyhow::bail!(
|
||||
"invalid session id '{id}': must not contain path separators"
|
||||
);
|
||||
}
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
if !path.exists() {
|
||||
anyhow::bail!("session not found: {id}");
|
||||
}
|
||||
let data = std::fs::read_to_string(&path)?;
|
||||
let session: Session = serde_json::from_str(&data)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()> {
|
||||
let dir = session.session_dir(base_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("session.json");
|
||||
let data = serde_json::to_string_pretty(session)?;
|
||||
let tmp = dir.join("session.json.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
// fsync before rename ensures the data is on disk.
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
// fsync the parent directory so the rename survives a crash.
|
||||
if let Some(parent) = dir.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()> {
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
anyhow::bail!(
|
||||
"invalid session id '{id}': must not contain path separators"
|
||||
);
|
||||
}
|
||||
let dir = base_dir.join("sessions").join(id);
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! zesdex-iam — Identity & Access Management crate.
|
||||
//!
|
||||
//! Clean Architecture / Domain-Driven Design structure:
|
||||
//!
|
||||
//! - **domain** — Pure entities and repository/service trait definitions
|
||||
//! - **application**— Use-case implementations of the service traits
|
||||
//! - **infrastructure** — Concrete persistence (filesystem) and HTTP adapter layers
|
||||
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
Reference in New Issue
Block a user