//! Typed appview error type. //! //! Handlers return `Result, AppError>` (or `Result` for `*/*` binary endpoints that need custom headers like //! `Content-Disposition`). `AppError` is a small typed enum (not a string //! bag): each variant maps to a fixed HTTP status and XRPC error code at the //! [`IntoResponse`] boundary, so the error *code* is a compile-time constant //! and only the human message is a `String`. Internal details are logged; the //! response body never carries secrets or raw errors. `AppError` implements //! `std::error::Error` so it can be used as a stream error type for streaming //! response bodies. use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cid::Cid as ParsedCid; use jacquard_axum::GenericXrpcErrorResponse; use sha2::{Digest, Sha256}; /// Appview handler error. Maps onto the XRPC error response shape. #[derive(Debug, thiserror::Error)] pub(crate) enum AppError { /// 401 `AuthenticationRequired`: strict OAuth extraction failed or an /// authenticated PDS operation reported an auth failure. #[error("AuthenticationRequired: {0}")] Unauthorized(String), /// 400 `InvalidRequest`: bad parameters (bad at-uri, unknown algorithm, /// unparseable cursor, unresolvable handle). #[error("InvalidRequest: {0}")] InvalidRequest(String), /// 404 `RecordNotFound`: the requested record does not exist in the index. #[error("RecordNotFound")] NotFound, /// 500 `InternalServerError`: unexpected failure (database, record decode, /// upstream bsky fetch). The message is safe to expose; details are logged. #[error("InternalServerError: {0}")] Internal(String), } pub(crate) type AppResult = Result; #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub(crate) enum ChunkIntegrityError { #[error("invalid chunk CID: {0}")] InvalidCid(String), #[error("unsupported chunk CID hash code {0}")] UnsupportedHash(u64), #[error("chunk CID digest mismatch")] DigestMismatch, } pub(crate) fn verify_chunk_cid(cid: &str, bytes: &[u8]) -> Result<(), ChunkIntegrityError> { let parsed = ParsedCid::try_from(cid) .map_err(|error| ChunkIntegrityError::InvalidCid(error.to_string()))?; if parsed.hash().code() != 0x12 { return Err(ChunkIntegrityError::UnsupportedHash(parsed.hash().code())); } let actual = Sha256::digest(bytes); if parsed.hash().digest() != &actual[..] { return Err(ChunkIntegrityError::DigestMismatch); } Ok(()) } pub(super) fn invalid_request(msg: impl Into) -> AppError { AppError::InvalidRequest(msg.into()) } pub(super) fn unauthorized(msg: impl Into) -> AppError { AppError::Unauthorized(msg.into()) } pub(super) fn not_found() -> AppError { AppError::NotFound } pub(super) fn internal(msg: impl Into) -> AppError { AppError::Internal(msg.into()) } /// Adapt a `sqlx::Error` into a safe 500, logging the real error server-side. pub(super) fn db(result: Result) -> AppResult { result.map_err(|e| { tracing::error!(error = %e, "database error"); AppError::Internal("database error".to_string()) }) } /// Clamp a page limit into [1, 100]. pub(super) fn clamp_limit(limit: Option) -> i64 { limit.unwrap_or(50).clamp(1, 100) } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code, message) = match self { AppError::Unauthorized(m) => { (StatusCode::UNAUTHORIZED, "AuthenticationRequired", Some(m)) } AppError::InvalidRequest(m) => (StatusCode::BAD_REQUEST, "InvalidRequest", Some(m)), AppError::NotFound => (StatusCode::NOT_FOUND, "RecordNotFound", None), AppError::Internal(m) => ( StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", Some(m), ), }; GenericXrpcErrorResponse::new(status, code, message).into_response() } }