feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs

- auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations
- domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors
- presentation: context/state/factory/errors/extractors/openapi modules
- routes behind /api, SPA served from root w/ fallback
- OpenAPI Scalar at /docs
- frontend ssr:false, single-binary Dockerfile
This commit is contained in:
2026-07-11 21:28:52 +02:00
parent 13031347cc
commit 7bd27d9b9c
50 changed files with 1604 additions and 213 deletions

View File

@@ -8,3 +8,5 @@ thiserror = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
email_address = "0.2"

View File

@@ -10,4 +10,10 @@ pub enum DomainError {
#[error("Infrastructure failure: {0}")]
InfrastructureError(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
}

View File

@@ -6,12 +6,14 @@ pub mod value_objects;
pub use errors::DomainError;
pub use models::{
ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong,
song_preview_chords,
ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
SongSummary, StoredSong, User, song_preview_chords,
};
pub use ports::{
FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort,
TabSource,
AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository,
SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository,
};
pub use services::{ChordTransposer, TransposeError};
pub use value_objects::{Chord, Note, SortField, SortOrder};
pub use value_objects::{
Chord, Email, Note, Password, PasswordHash, SortField, SortOrder, UserId, Username,
};

View File

@@ -1,3 +1,7 @@
pub mod refresh_session;
pub mod song;
pub mod user;
pub use refresh_session::*;
pub use song::*;
pub use user::*;

View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::value_objects::UserId;
pub struct GeneratedToken {
pub token: String,
pub expires_at: DateTime<Utc>,
}
pub struct RefreshSession {
pub id: Uuid,
pub user_id: UserId,
pub token: String,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,50 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[derive(Debug, Clone)]
pub struct User {
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
}
impl User {
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
Self {
id: UserId::generate(),
email,
username,
password_hash,
}
}
pub fn from_persistence(
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
) -> Self {
Self {
id,
email,
username,
password_hash,
}
}
pub fn id(&self) -> &UserId {
&self.id
}
pub fn email(&self) -> &Email {
&self.email
}
pub fn username(&self) -> &Username {
&self.username
}
pub fn password_hash(&self) -> &PasswordHash {
&self.password_hash
}
}

View File

@@ -0,0 +1,34 @@
use async_trait::async_trait;
use crate::errors::DomainError;
use crate::models::{GeneratedToken, RefreshSession, User};
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[async_trait]
pub trait AuthService: Send + Sync {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
}
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RefreshSessionRepository: Send + Sync {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
async fn delete_expired(&self) -> Result<u64, DomainError>;
}

View File

@@ -1,5 +1,7 @@
pub mod auth;
pub mod repository;
pub mod tab_source;
pub use auth::*;
pub use repository::*;
pub use tab_source::*;

View File

@@ -0,0 +1,18 @@
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Uuid);
impl UserId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn value(&self) -> Uuid {
self.0
}
}

View File

@@ -1,7 +1,11 @@
mod chord;
mod ids;
mod note;
mod sorting;
mod user;
pub use chord::*;
pub use ids::*;
pub use note::*;
pub use sorting::*;
pub use user::*;

View File

@@ -0,0 +1,107 @@
use crate::errors::DomainError;
#[derive(Clone, PartialEq, Eq)]
pub struct Email(String);
impl Email {
pub fn new(email: &str) -> Result<Self, DomainError> {
let trimmed = email.trim().to_lowercase();
if !email_address::EmailAddress::is_valid(&trimmed) {
return Err(DomainError::ValidationError(format!(
"invalid email: {trimmed}"
)));
}
Ok(Self(trimmed))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Email({})", self.0)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Username(String);
impl Username {
pub fn new(username: &str) -> Result<Self, DomainError> {
let normalized = username.trim().to_lowercase();
if normalized.len() < 2 || normalized.len() > 30 {
return Err(DomainError::ValidationError(
"username must be 2-30 characters".into(),
));
}
if !normalized
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(DomainError::ValidationError(
"username may only contain alphanumeric characters, underscores, and dashes".into(),
));
}
Ok(Self(normalized))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Username {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Username({})", self.0)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PasswordHash(String);
impl PasswordHash {
pub fn new(hash: String) -> Result<Self, DomainError> {
if hash.is_empty() {
return Err(DomainError::ValidationError(
"password hash cannot be empty".into(),
));
}
Ok(Self(hash))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PasswordHash([REDACTED])")
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Password(String);
impl Password {
pub fn new(password: &str) -> Result<Self, DomainError> {
if password.len() < 8 {
return Err(DomainError::ValidationError(
"password must be at least 8 characters".into(),
));
}
Ok(Self(password.to_string()))
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Password([REDACTED])")
}
}