Skip to content
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ Tests need no service container: `test_app()` builds a `Config` over a `TempDir`

- **Library/binary split.** `src/main.rs` only loads config, dispatches CLI commands and serves. The router is built by `waveflow_server::app(&config, state)` in `src/lib.rs`, so tests spawn the same app in-process. Put logic behind `app()`, not in `main`.
- **`AppState`** (`src/lib.rs`) holds the shared singletons: `db`, `auth`, `secret_box`, `scanner`, `media`, `services`, plus config values copied in (`artwork_dir`, `public_url`, `stream_ticket_ttl`). Add new singletons there.
- **Three directories carry the surfaces.** `src/api/` is `/api/v2`, one module per resource plus `error`, `access` and `web_session`; `src/subsonic/` is the façade, one module per method family; `src/services/` is the domain, several `impl DomainServices` blocks split by domain. Each `mod.rs` keeps only what its children share — the router or dispatch, the cross-cutting types, and for `services` the SQL projection macros, which sit ahead of the `mod` declarations so their textual scope reaches every child. File a new handler under the resource it serves, not in `mod.rs`.
- **All environment access lives in `src/config.rs`.** Every tunable is a field on `Config` with its env var documented on it.
- **No SQL in handlers.** SQL belongs in `src/database.rs`, `src/catalog.rs` or `src/services.rs`; handlers orchestrate HTTP only.
- **One set of domain services.** `DomainServices` (`src/services.rs`) is the single implementation behind the native API, the Subsonic façade and the web client. A mutation reachable from two surfaces must call the same method — that convergence is the point of M4, and duplicating logic per surface is how the two drift.
- **No SQL in handlers.** SQL belongs in `src/database.rs`, `src/catalog.rs` or `src/services/`; handlers orchestrate HTTP only.
- **One set of domain services.** `DomainServices` (`src/services/`) is the single implementation behind the native API, the Subsonic façade and the web client. A mutation reachable from two surfaces must call the same method — that convergence is the point of M4, and duplicating logic per surface is how the two drift.
- **Tenancy is enforced in the queries**, through `library_member`, not in handlers. The shared projections are `song_select!` / `album_select!` / `artist_select!` macros that `concat!` into literals: sqlx only accepts static SQL, so composing them stays injection-proof by construction. The first bind is always the user id.
- **404 blurs everything.** A resource that is missing, and one that belongs to another account, answer identically. `ServiceError::Forbidden` maps onto 404 for that reason. Never confirm existence to someone not entitled to it.
- **SQLite discipline.** WAL, foreign keys, `busy_timeout`. Every mutation takes the process-wide writer gate (`db.writer_guard()`); libraries may scan concurrently but never open independent writers. `NULL` sorts first in SQLite — order explicitly with `NULLS LAST` wherever a missing tag would otherwise jump the queue.
Expand Down
143 changes: 143 additions & 0 deletions src/api/access.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Who is calling, and what their token is allowed to do.
//!
//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged.

use super::*;

/// What a route needs of the credential it was called with.
///
/// Chosen at every call of [`authenticated`], which is the only way into a
/// route, so a new route cannot be written without deciding: the compiler asks
/// the question. That is the whole reason this is a parameter rather than a
/// second helper a handler may forget to call — which is exactly what happened
/// to the scope list, stored since the foundations and read by nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Access {
/// Reads the caller's own catalogue and user data.
Read,
/// Writes on the caller's behalf: playlists, favorites, ratings, the
/// queue, bookmarks, shares, scrobbles and scans.
Write,
/// Acts on the instance: accounts, libraries, memberships, credentials.
Admin,
}

/// The scope that admits the administrative routes.
pub(super) const ADMIN_SCOPE: &str = "admin";

/// The scope that admits any mutation.
pub(super) const WRITE_SCOPE: &str = "write";

impl Access {
/// Whether a credential carrying `scopes` may do this.
///
/// An empty list is unrestricted: a session, an OAuth grant and a token
/// issued without scopes all carry the account's full authority, so nothing
/// that works today stops working.
///
/// A non-empty list grants only what it names, and a name this server does
/// not know grants nothing — so `catalog:read` reads and does no more,
/// without needing a vocabulary of every possible scope. `admin` implies
/// `write`: a credential trusted to create accounts is not usefully barred
/// from creating a playlist, and the surprise would be the other way round.
fn granted_by(self, scopes: &[String]) -> bool {
if scopes.is_empty() {
return true;
}
let holds = |wanted: &str| scopes.iter().any(|scope| scope == wanted);
match self {
Self::Read => true,
Self::Write => holds(WRITE_SCOPE) || holds(ADMIN_SCOPE),
Self::Admin => holds(ADMIN_SCOPE),
}
}
Comment thread
InstaZDLL marked this conversation as resolved.
}

/// Resolves the caller and checks, in one place, that the credential may do
/// what the route is about to do.
///
/// Both halves of administrative authority live here: an active administrator,
/// on a credential that has not been narrowed away from it. A token cannot
/// promote an ordinary account, and an administrator's token is not widened by
/// whose account it belongs to.
///
/// It could widen itself, once: minting a session through the authorization
/// code flow returned one carrying the account's whole authority, whatever the
/// credential that asked. That is closed where it belongs now — the grant
/// records the caller's scopes and the session inherits them — rather than by
/// a rule this function has to know about.
pub(crate) async fn authenticated(
state: &AppState,
headers: &HeaderMap,
access: Access,
) -> Result<crate::authentication::AuthUser, ApiError> {
let token = bearer_token(headers).ok_or(ApiError::Unauthorized)?;
let user = state
.auth
.authenticate(token)
.await
.map_err(ApiError::from)?;
let role_ok = access != Access::Admin || user.role == crate::database::AccountRole::Admin;
if role_ok && access.granted_by(&user.scopes) {
Ok(user)
} else {
Err(ApiError::Forbidden)
}
}

/// The bearer token, whatever case the client spelled the scheme in.
///
/// RFC 7235 §2.1 makes the scheme name case-insensitive, so `bearer` is as
/// valid as `Bearer`. Matching the spelling exactly turned a conforming client
/// away as unauthenticated.
pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let (scheme, token) = headers
.get(header::AUTHORIZATION)?
.to_str()
.ok()?
.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then_some(token)
.filter(|token| !token.is_empty())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pub(super) async fn mutation_context(
state: &AppState,
headers: &HeaderMap,
user_id: Uuid,
) -> Result<crate::sync::MutationContext, ApiError> {
let operation_id =
optional_uuid_header(headers, OPERATION_ID_HEADER)?.unwrap_or_else(Uuid::new_v4);
let origin_device_id = optional_uuid_header(headers, DEVICE_ID_HEADER)?;
if let Some(device_id) = origin_device_id {
let owned = state
.sync
.device_belongs_to_user(user_id, device_id)
.await
.map_err(db_error)?;
if !owned {
return Err(ApiError::Validation);
}
}
Ok(crate::sync::MutationContext {
operation_id,
origin_device_id,
})
}

pub(super) fn optional_uuid_header(
headers: &HeaderMap,
name: &'static str,
) -> Result<Option<Uuid>, ApiError> {
headers
.get(name)
.map(|value| {
value
.to_str()
.ok()
.and_then(|value| Uuid::parse_str(value).ok())
.ok_or(ApiError::Validation)
})
.transpose()
}
178 changes: 178 additions & 0 deletions src/api/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! Login, refresh and logout, for native clients and for the browser.
//!
//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged.

use super::*;

#[derive(Debug, Deserialize, ToSchema)]
pub struct LoginRequest {
pub username: String,
pub password: String,
pub device_name: String,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}

#[utoipa::path(
post,
path = "/api/v2/auth/login",
tag = "authentication",
request_body = LoginRequest,
responses(
(status = 200, body = crate::authentication::AuthTokens),
(status = 401, body = ErrorResponse),
(status = 422, body = ErrorResponse),
(status = 503, body = ErrorResponse)
)
)]
pub async fn login(
State(state): State<AppState>,
Json(request): Json<LoginRequest>,
) -> Result<Json<crate::authentication::AuthTokens>, ApiError> {
state
.auth
.login(&request.username, &request.password, &request.device_name)
.await
.map(Json)
.map_err(ApiError::from)
}

#[utoipa::path(
post,
path = "/api/v2/auth/refresh",
tag = "authentication",
request_body = RefreshRequest,
responses(
(status = 200, body = crate::authentication::AuthTokens),
(status = 401, body = ErrorResponse),
(status = 503, body = ErrorResponse)
)
)]
pub async fn refresh(
State(state): State<AppState>,
Json(request): Json<RefreshRequest>,
) -> Result<Json<crate::authentication::AuthTokens>, ApiError> {
state
.auth
.refresh(&request.refresh_token)
.await
.map(Json)
.map_err(ApiError::from)
}

#[utoipa::path(
post,
path = "/api/v2/auth/logout",
tag = "authentication",
responses(
(status = 204),
(status = 401, body = ErrorResponse),
(status = 503, body = ErrorResponse)
)
)]
pub async fn logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
let access_token = bearer_token(&headers).ok_or(ApiError::Unauthorized)?;
state
.auth
.logout(access_token)
.await
.map_err(ApiError::from)?;
Ok(StatusCode::NO_CONTENT)
}

/// Browser sessions keep only the short-lived access token in JavaScript. The
/// rotating refresh token is an HttpOnly, same-site cookie and is therefore
/// never exposed to the embedded SPA.
#[utoipa::path(
post,
path = "/api/v2/web/auth/login",
tag = "authentication",
request_body = LoginRequest,
responses(
(status = 200, body = WebAuthResponse),
(status = 401, body = ErrorResponse),
(status = 403, body = ErrorResponse),
(status = 422, body = ErrorResponse),
(status = 503, body = ErrorResponse)
)
)]
Comment thread
InstaZDLL marked this conversation as resolved.
pub async fn web_login(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<LoginRequest>,
) -> Result<Response, ApiError> {
validate_web_origin(&state, &headers)?;
let tokens = state
.auth
.login(&request.username, &request.password, &request.device_name)
.await
.map_err(ApiError::from)?;
web_auth_response(&state, &headers, tokens)
}

#[utoipa::path(
post,
path = "/api/v2/web/auth/refresh",
tag = "authentication",
responses(
(status = 200, body = WebAuthResponse),
(status = 401, body = ErrorResponse),
(status = 403, body = ErrorResponse),
(status = 503, body = ErrorResponse)
)
)]
pub async fn web_refresh(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
validate_web_request(&state, &headers)?;
let refresh_token = cookie_value(&headers, WEB_REFRESH_COOKIE).ok_or(ApiError::Unauthorized)?;
let tokens = state
.auth
.refresh(refresh_token)
.await
.map_err(ApiError::from)?;
web_auth_response(&state, &headers, tokens)
}

#[utoipa::path(
post,
path = "/api/v2/web/auth/logout",
tag = "authentication",
responses(
(status = 204),
(status = 401, body = ErrorResponse),
(status = 403, body = ErrorResponse),
(status = 503, body = ErrorResponse)
)
)]
pub async fn web_logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
validate_web_request(&state, &headers)?;
let result = match cookie_value(&headers, WEB_REFRESH_COOKIE) {
Some(refresh_token) => state.auth.revoke_refresh(refresh_token).await,
None => Err(AuthError::InvalidRefreshToken),
};
let mut response = match result {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(error) => ApiError::from(error).into_response(),
};
let secure = secure_cookies(&state);
append_cookie(
&mut response,
expired_cookie(WEB_REFRESH_COOKIE, true, secure),
)?;
append_cookie(
&mut response,
expired_cookie(WEB_CSRF_COOKIE, false, secure),
)?;
Ok(response)
}
Loading
Loading