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

@@ -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])")
}
}