feat(api): implement user authentication and registration endpoints

- Add main application logic in `api/src/main.rs` to initialize server, database, and services.
- Create authentication routes in `api/src/routes/auth.rs` for login, register, logout, and user info retrieval.
- Implement configuration route in `api/src/routes/config.rs` to expose application settings.
- Define application state management in `api/src/state.rs` to share user service and configuration.
- Set up Docker Compose configuration in `compose.yml` for backend, worker, and database services.
- Establish domain logic in `domain` crate with user entities, repositories, and services.
- Implement SQLite user repository in `infra/src/user_repository.rs` for user data persistence.
- Create database migration handling in `infra/src/db.rs` and session store in `infra/src/session_store.rs`.
- Add necessary dependencies and features in `Cargo.toml` files for both `domain` and `infra` crates.
This commit is contained in:
2026-01-02 13:07:09 +01:00
parent 7dbdf3f00b
commit 1d141c7a97
27 changed files with 208 additions and 130 deletions

18
domain/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "domain"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.146"
thiserror = "2.0.17"
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
futures-core = "0.3"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

64
domain/src/entities.rs Normal file
View File

@@ -0,0 +1,64 @@
//! Domain entities
//!
//! This module contains pure domain types with no I/O dependencies.
//! These represent the core business concepts of the application.
pub use crate::value_objects::{Email, UserId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user in the system.
///
/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub subject: String,
pub email: Email,
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(subject: impl Into<String>, email: Email) -> Self {
Self {
id: Uuid::new_v4(),
subject: subject.into(),
email,
password_hash: None,
created_at: Utc::now(),
}
}
pub fn with_id(
id: Uuid,
subject: impl Into<String>,
email: Email,
password_hash: Option<String>,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
subject: subject.into(),
email,
password_hash,
created_at,
}
}
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
subject: format!("local|{}", Uuid::new_v4()),
email,
password_hash: Some(password_hash.into()),
created_at: Utc::now(),
}
}
/// Helper to get email as string
pub fn email_str(&self) -> &str {
self.email.as_ref()
}
}

67
domain/src/errors.rs Normal file
View File

@@ -0,0 +1,67 @@
//! Domain errors for K-Notes
//!
//! Uses `thiserror` for ergonomic error definitions.
//! These errors represent domain-level failures and will be mapped
//! to HTTP status codes in the API layer.
use thiserror::Error;
use uuid::Uuid;
/// Domain-level errors for K-Notes operations
#[derive(Debug, Error)]
pub enum DomainError {
/// The requested user was not found
#[error("User not found: {0}")]
UserNotFound(Uuid),
/// User with this email/subject already exists
#[error("User already exists: {0}")]
UserAlreadyExists(String),
/// A validation error occurred
#[error("Validation error: {0}")]
ValidationError(String),
/// User is not authorized to perform this action
#[error("Unauthorized: {0}")]
Unauthorized(String),
/// A repository/infrastructure error occurred
#[error("Repository error: {0}")]
RepositoryError(String),
/// An infrastructure adapter error occurred
#[error("Infrastructure error: {0}")]
InfrastructureError(String),
}
impl DomainError {
/// Create a validation error
pub fn validation(message: impl Into<String>) -> Self {
Self::ValidationError(message.into())
}
/// Create an unauthorized error
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Unauthorized(message.into())
}
/// Check if this error indicates a "not found" condition
pub fn is_not_found(&self) -> bool {
matches!(self, DomainError::UserNotFound(_))
}
/// Check if this error indicates a conflict (already exists)
pub fn is_conflict(&self) -> bool {
matches!(self, DomainError::UserAlreadyExists(_))
}
}
impl From<crate::value_objects::ValidationError> for DomainError {
fn from(error: crate::value_objects::ValidationError) -> Self {
DomainError::ValidationError(error.to_string())
}
}
/// Result type alias for domain operations
pub type DomainResult<T> = Result<T, DomainError>;

17
domain/src/lib.rs Normal file
View File

@@ -0,0 +1,17 @@
//! Domain Logic
//!
//! This crate contains the core business logic, entities, and repository interfaces.
//! It is completely independent of the infrastructure layer (databases, HTTP, etc.).
pub mod entities;
pub mod errors;
pub mod repositories;
pub mod services;
pub mod value_objects;
// Re-export commonly used types
pub use entities::*;
pub use errors::{DomainError, DomainResult};
pub use repositories::*;
pub use services::UserService;
pub use value_objects::*;

View File

@@ -0,0 +1,28 @@
//! Reference Repository ports (traits)
//!
//! These traits define the interface for data persistence.
use async_trait::async_trait;
use uuid::Uuid;
use crate::entities::User;
use crate::errors::DomainResult;
/// Repository port for User persistence
#[async_trait]
pub trait UserRepository: Send + Sync {
/// Find a user by their internal ID
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>>;
/// Find a user by their OIDC subject (used for authentication)
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
/// Find a user by their email
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
/// Save a new user or update an existing one
async fn save(&self, user: &User) -> DomainResult<()>;
/// Delete a user by their ID
async fn delete(&self, id: Uuid) -> DomainResult<()>;
}

57
domain/src/services.rs Normal file
View File

@@ -0,0 +1,57 @@
//! Domain Services
//!
//! Services contain the business logic of the application.
use std::sync::Arc;
use uuid::Uuid;
use crate::entities::User;
use crate::errors::{DomainError, DomainResult};
use crate::repositories::UserRepository;
use crate::value_objects::Email;
/// Service for managing users
pub struct UserService {
user_repository: Arc<dyn UserRepository>,
}
impl UserService {
pub fn new(user_repository: Arc<dyn UserRepository>) -> Self {
Self { user_repository }
}
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<User> {
// 1. Try to find by subject (OIDC id)
if let Some(user) = self.user_repository.find_by_subject(subject).await? {
return Ok(user);
}
// 2. Try to find by email
if let Some(mut user) = self.user_repository.find_by_email(email).await? {
// Link subject if missing (account linking logic)
if user.subject != subject {
user.subject = subject.to_string();
self.user_repository.save(&user).await?;
}
return Ok(user);
}
// 3. Create new user
let email = Email::try_from(email)?;
let user = User::new(subject, email);
self.user_repository.save(&user).await?;
Ok(user)
}
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<User> {
self.user_repository
.find_by_id(id)
.await?
.ok_or(DomainError::UserNotFound(id))
}
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
self.user_repository.find_by_email(email).await
}
}

242
domain/src/value_objects.rs Normal file
View File

@@ -0,0 +1,242 @@
//! Value Objects for K-Notes Domain
//!
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use thiserror::Error;
use uuid::Uuid;
pub type UserId = Uuid;
// ============================================================================
// Validation Error
// ============================================================================
/// Errors that occur when parsing/validating value objects
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ValidationError {
#[error("Invalid email format: {0}")]
InvalidEmail(String),
#[error("Password must be at least {min} characters, got {actual}")]
PasswordTooShort { min: usize, actual: usize },
}
// ============================================================================
// Email
// ============================================================================
/// A validated email address.
///
/// Simple validation: must contain exactly one `@` with non-empty parts on both sides.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(String);
impl Email {
/// Minimum validation: contains @ with non-empty local and domain parts
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
let trimmed = value.trim().to_lowercase();
// Basic email validation
let parts: Vec<&str> = trimmed.split('@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(ValidationError::InvalidEmail(value));
}
// Domain must contain at least one dot
if !parts[1].contains('.') {
return Err(ValidationError::InvalidEmail(value));
}
Ok(Self(trimmed))
}
/// Get the inner value
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for Email {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Email {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl Serialize for Email {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Email {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// ============================================================================
// Password
// ============================================================================
/// A validated password input (NOT the hash).
///
/// Enforces minimum length of 6 characters.
#[derive(Clone, PartialEq, Eq)]
pub struct Password(String);
/// Minimum password length
pub const MIN_PASSWORD_LENGTH: usize = 6;
impl Password {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.len() < MIN_PASSWORD_LENGTH {
return Err(ValidationError::PasswordTooShort {
min: MIN_PASSWORD_LENGTH,
actual: value.len(),
});
}
Ok(Self(value))
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Password {
fn as_ref(&self) -> &str {
&self.0
}
}
// Intentionally hide password content in Debug
impl fmt::Debug for Password {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Password(***)")
}
}
impl TryFrom<String> for Password {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Password {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl<'de> Deserialize<'de> for Password {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// Note: Password should NOT implement Serialize to prevent accidental exposure
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
mod email_tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(Email::new("user@example.com").is_ok());
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
}
#[test]
fn test_email_normalizes() {
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
assert_eq!(email.as_ref(), "user@example.com");
}
#[test]
fn test_invalid_email_no_at() {
assert!(Email::new("userexample.com").is_err());
}
#[test]
fn test_invalid_email_no_domain() {
assert!(Email::new("user@").is_err());
}
#[test]
fn test_invalid_email_no_local() {
assert!(Email::new("@example.com").is_err());
}
#[test]
fn test_invalid_email_no_dot_in_domain() {
assert!(Email::new("user@localhost").is_err());
}
}
mod password_tests {
use super::*;
#[test]
fn test_valid_password() {
assert!(Password::new("secret123").is_ok());
assert!(Password::new("123456").is_ok()); // Exactly 6 chars
}
#[test]
fn test_password_too_short() {
assert!(Password::new("12345").is_err()); // 5 chars
assert!(Password::new("").is_err());
}
#[test]
fn test_password_debug_hides_content() {
let password = Password::new("supersecret").unwrap();
let debug = format!("{:?}", password);
assert!(!debug.contains("supersecret"));
assert!(debug.contains("***"));
}
}
}