#![recursion_limit = "256"]
pub mod callback;
pub mod config;
pub mod errors;
pub mod handlers;
pub mod middleware;
pub mod models;
pub mod repositories;
pub mod services;
pub mod storage;
pub mod utils;
mod router;
pub use callback::{AuthCallback, AuthCallbackPayload, NoopCallback};
pub use config::{Config, DatabaseConfig, NotificationConfig};
pub use errors::AppError;
pub use router::create_router;
pub use services::NotificationService;
pub use services::{
EmailService, InstantLinkEmailData, LogEmailService, NoopEmailService, PasswordResetEmailData,
VerificationEmailData,
};
pub use storage::Storage;
#[cfg(feature = "postgres")]
pub use sqlx::PgPool;
use axum::Router;
use repositories::{
ApiKeyRepository, AuditLogRepository, CredentialRepository, CreditHoldRepository,
CreditRefundRequestRepository, CreditRepository, CustomRoleRepository, DepositRepository,
InviteRepository,
LoginAttemptConfig, LoginAttemptRepository, MembershipRepository, NonceRepository,
OrgRepository, OutboxRepository, PolicyRepository, PrivacyNoteRepository, SessionRepository,
SystemSettingsRepository, TotpRepository, TreasuryConfigRepository, UserRepository,
VerificationRepository, WalletMaterialRepository, WebAuthnRepository,
};
use services::{
create_wallet_unlock_cache, AppleService, AuditService, CommsService, DepositCreditService,
DepositFeeService, EncryptionService, GoogleService, JupiterSwapService, JwtService,
MfaAttemptService, NoteEncryptionService, OidcService, PasswordService, PrivacySidecarClient,
SettingsService, SidecarClientConfig, SolPriceService, SolanaService, StepUpService,
TotpService, WalletSigningService, WalletUnlockCache, WebAuthnService,
};
use std::sync::Arc;
use utils::TokenCipher;
fn build_privacy_sidecar_client(config: &Config) -> Result<PrivacySidecarClient, AppError> {
let api_key = config
.privacy
.sidecar_api_key
.clone()
.ok_or_else(|| AppError::Config("SIDECAR_API_KEY is required".into()))?;
PrivacySidecarClient::new(SidecarClientConfig {
base_url: config.privacy.sidecar_url.clone(),
timeout_ms: config.privacy.sidecar_timeout_ms,
api_key,
})
}
fn decode_note_encryption_key(key: &str) -> Result<Vec<u8>, base64::DecodeError> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(key)
}
fn build_note_encryption_service(
key_bytes: &[u8],
key_id: &str,
) -> Result<NoteEncryptionService, AppError> {
NoteEncryptionService::new(key_bytes, key_id)
}
pub struct AppState<C: AuthCallback, E: EmailService = LogEmailService> {
pub config: Config,
pub callback: Arc<C>,
pub jwt_service: JwtService,
pub password_service: PasswordService,
pub google_service: GoogleService,
pub apple_service: AppleService,
pub solana_service: SolanaService,
pub totp_service: TotpService,
pub webauthn_service: WebAuthnService,
pub oidc_service: OidcService,
pub encryption_service: EncryptionService,
pub phantom_email: std::marker::PhantomData<E>,
pub audit_service: AuditService,
pub comms_service: CommsService,
pub user_repo: Arc<dyn UserRepository>,
pub session_repo: Arc<dyn SessionRepository>,
pub nonce_repo: Arc<dyn NonceRepository>,
pub verification_repo: Arc<dyn VerificationRepository>,
pub org_repo: Arc<dyn OrgRepository>,
pub membership_repo: Arc<dyn MembershipRepository>,
pub invite_repo: Arc<dyn InviteRepository>,
pub audit_repo: Arc<dyn AuditLogRepository>,
pub login_attempt_repo: Arc<dyn LoginAttemptRepository>,
pub login_attempt_config: LoginAttemptConfig,
pub totp_repo: Arc<dyn TotpRepository>,
pub custom_role_repo: Arc<dyn CustomRoleRepository>,
pub policy_repo: Arc<dyn PolicyRepository>,
pub outbox_repo: Arc<dyn OutboxRepository>,
pub api_key_repo: Arc<dyn ApiKeyRepository>,
pub wallet_material_repo: Arc<dyn WalletMaterialRepository>,
pub credential_repo: Arc<dyn CredentialRepository>,
pub webauthn_repo: Arc<dyn WebAuthnRepository>,
pub deposit_repo: Arc<dyn DepositRepository>,
pub credit_repo: Arc<dyn CreditRepository>,
pub credit_hold_repo: Arc<dyn CreditHoldRepository>,
pub credit_refund_request_repo: Arc<dyn CreditRefundRequestRepository>,
pub privacy_note_repo: Arc<dyn PrivacyNoteRepository>,
pub system_settings_repo: Arc<dyn SystemSettingsRepository>,
pub treasury_config_repo: Arc<dyn TreasuryConfigRepository>,
pub settings_service: Arc<SettingsService>,
pub mfa_attempt_service: MfaAttemptService,
pub step_up_service: StepUpService,
pub wallet_signing_service: WalletSigningService,
pub wallet_unlock_cache: Arc<WalletUnlockCache>,
pub storage: Storage,
pub privacy_sidecar_client: Option<Arc<PrivacySidecarClient>>,
pub note_encryption_service: Option<Arc<NoteEncryptionService>>,
pub sol_price_service: Arc<SolPriceService>,
pub jupiter_swap_service: Option<Arc<JupiterSwapService>>,
pub deposit_credit_service: Arc<DepositCreditService>,
#[cfg(feature = "postgres")]
pub postgres_pool: Option<PgPool>,
}
pub fn router<C: AuthCallback + 'static>(config: Config, callback: Arc<C>) -> Router {
router_with_storage(config, callback, Storage::in_memory())
}
pub fn router_with_storage<C: AuthCallback + 'static>(
config: Config,
callback: Arc<C>,
storage: Storage,
) -> Router {
let jwt_service = JwtService::new(&config.jwt);
let password_service = PasswordService::default();
let google_service = GoogleService::new(&config.google);
let apple_service = AppleService::new(&config.apple);
let solana_service = SolanaService::new(&config.solana, "Cedros Login".to_string());
let totp_service = TotpService::new("Cedros");
let webauthn_service = WebAuthnService::new(&config.webauthn);
let audit_service = AuditService::new(storage.audit_repo.clone(), config.server.trust_proxy);
let step_up_service = StepUpService::new(storage.session_repo.clone());
let protocol = if config
.server
.frontend_url
.as_ref()
.map(|u| u.starts_with("https://"))
.unwrap_or(false)
{
"https"
} else {
"http"
};
let sso_callback_url = config.server.sso_callback_url.clone().unwrap_or_else(|| {
format!(
"{}://{}:{}/auth/sso/callback",
protocol, config.server.host, config.server.port
)
});
let oidc_service = OidcService::new(sso_callback_url);
let encryption_service = EncryptionService::from_secret(&config.jwt.secret);
let base_url = config
.server
.frontend_url
.clone()
.unwrap_or_else(|| "http://localhost:3000".to_string());
let token_cipher = TokenCipher::new(&config.jwt.secret);
let comms_service = CommsService::new(storage.outbox_repo.clone(), base_url, token_cipher);
let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo.clone()));
let (privacy_sidecar_client, note_encryption_service) = if config.privacy.enabled {
let mut errors = Vec::new();
let sidecar = match build_privacy_sidecar_client(&config) {
Ok(s) => Some(Arc::new(s)),
Err(e) => {
errors.push(format!("Failed to create privacy sidecar client: {}", e));
None
}
};
let note_encryption = match config.privacy.note_encryption_key.as_ref() {
Some(key) => match decode_note_encryption_key(key) {
Ok(key_bytes) => match build_note_encryption_service(&key_bytes, &config.privacy.note_encryption_key_id) {
Ok(n) => Some(Arc::new(n)),
Err(e) => {
errors.push(format!("Failed to create note encryption service: {}", e));
None
}
},
Err(e) => {
errors.push(format!("Invalid base64 in note_encryption_key: {}", e));
None
}
},
None => {
errors.push("note_encryption_key is required when privacy is enabled".to_string());
None
}
};
if !errors.is_empty() {
for error in &errors {
tracing::error!("{}, privacy features disabled", error);
}
(None, None)
} else {
(sidecar, note_encryption)
}
} else {
(None, None)
};
let jupiter_swap_service = config
.privacy
.company_wallet_address
.as_ref()
.and_then(|wallet| {
match JupiterSwapService::new(
wallet.clone(),
&config.privacy.company_currency,
None, ) {
Ok(service) => Some(Arc::new(service)),
Err(e) => {
tracing::error!(error = %e, "Failed to create Jupiter swap service, swap features disabled");
None
}
}
});
let sol_price_service = Arc::new(SolPriceService::new());
let fee_service = Arc::new(DepositFeeService::new(settings_service.clone()));
let deposit_credit_service = Arc::new(DepositCreditService::new(
sol_price_service.clone(),
fee_service,
config.privacy.company_currency.clone(),
));
let state = Arc::new(AppState {
config,
callback,
jwt_service,
password_service,
google_service,
apple_service,
solana_service,
totp_service,
webauthn_service,
oidc_service,
encryption_service,
phantom_email: std::marker::PhantomData::<LogEmailService>,
audit_service,
comms_service,
user_repo: storage.user_repo.clone(),
session_repo: storage.session_repo.clone(),
nonce_repo: storage.nonce_repo.clone(),
verification_repo: storage.verification_repo.clone(),
org_repo: storage.org_repo.clone(),
membership_repo: storage.membership_repo.clone(),
invite_repo: storage.invite_repo.clone(),
audit_repo: storage.audit_repo.clone(),
login_attempt_repo: storage.login_attempt_repo.clone(),
login_attempt_config: LoginAttemptConfig::default(),
totp_repo: storage.totp_repo.clone(),
custom_role_repo: storage.custom_role_repo.clone(),
policy_repo: storage.policy_repo.clone(),
outbox_repo: storage.outbox_repo.clone(),
api_key_repo: storage.api_key_repo.clone(),
wallet_material_repo: storage.wallet_material_repo.clone(),
credential_repo: storage.credential_repo.clone(),
webauthn_repo: storage.webauthn_repo.clone(),
deposit_repo: storage.deposit_repo.clone(),
credit_repo: storage.credit_repo.clone(),
credit_hold_repo: storage.credit_hold_repo.clone(),
credit_refund_request_repo: storage.credit_refund_request_repo.clone(),
privacy_note_repo: storage.privacy_note_repo.clone(),
system_settings_repo: storage.system_settings_repo.clone(),
treasury_config_repo: storage.treasury_config_repo.clone(),
settings_service: settings_service.clone(),
mfa_attempt_service: MfaAttemptService::new(),
step_up_service,
wallet_signing_service: WalletSigningService::new(),
wallet_unlock_cache: create_wallet_unlock_cache(),
privacy_sidecar_client,
note_encryption_service,
sol_price_service,
jupiter_swap_service,
deposit_credit_service,
#[cfg(feature = "postgres")]
postgres_pool: storage.pg_pool.clone(),
storage,
});
create_router(state)
}
pub fn create_withdrawal_worker(
config: &Config,
storage: &Storage,
settings_service: Arc<SettingsService>,
notification_service: Arc<dyn services::NotificationService>,
cancel_token: tokio_util::sync::CancellationToken,
) -> Option<tokio::task::JoinHandle<()>> {
if !config.privacy.enabled {
return None;
}
let sidecar = match build_privacy_sidecar_client(config) {
Ok(s) => Arc::new(s),
Err(e) => {
tracing::error!(error = %e, "Failed to create privacy sidecar client for withdrawal worker");
return None;
}
};
let encryption_key = config
.privacy
.note_encryption_key
.as_ref()
.expect("note_encryption_key is required when privacy is enabled");
let key_bytes = match decode_note_encryption_key(encryption_key) {
Ok(k) => k,
Err(e) => {
tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
return None;
}
};
let note_encryption = match build_note_encryption_service(
&key_bytes,
&config.privacy.note_encryption_key_id,
) {
Ok(s) => Arc::new(s),
Err(e) => {
tracing::error!(error = %e, "Failed to create note encryption service for withdrawal worker");
return None;
}
};
use services::{WithdrawalWorker, WithdrawalWorkerConfig};
let worker_config = WithdrawalWorkerConfig {
company_currency: config.privacy.company_currency.clone(),
};
let worker = WithdrawalWorker::new(
storage.deposit_repo.clone(),
storage.withdrawal_history_repo.clone(),
sidecar,
note_encryption,
notification_service,
settings_service,
worker_config,
);
Some(worker.start(cancel_token))
}
pub fn create_micro_batch_worker(
config: &Config,
storage: &Storage,
settings_service: Arc<SettingsService>,
cancel_token: tokio_util::sync::CancellationToken,
) -> Option<tokio::task::JoinHandle<()>> {
if !config.privacy.enabled {
return None;
}
let sidecar = match build_privacy_sidecar_client(config) {
Ok(s) => Arc::new(s),
Err(e) => {
tracing::error!(error = %e, "Failed to create privacy sidecar client for micro batch worker");
return None;
}
};
let encryption_key = config
.privacy
.note_encryption_key
.as_ref()
.expect("note_encryption_key is required when privacy is enabled");
let key_bytes = match decode_note_encryption_key(encryption_key) {
Ok(k) => k,
Err(e) => {
tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
return None;
}
};
let note_encryption = match build_note_encryption_service(
&key_bytes,
&config.privacy.note_encryption_key_id,
) {
Ok(s) => Arc::new(s),
Err(e) => {
tracing::error!(error = %e, "Failed to create note encryption service for micro batch worker");
return None;
}
};
let sol_price_service = Arc::new(services::SolPriceService::new());
use services::MicroBatchWorker;
let worker = MicroBatchWorker::new(
storage.deposit_repo.clone(),
storage.treasury_config_repo.clone(),
sidecar,
sol_price_service,
note_encryption,
settings_service,
config.privacy.company_currency.clone(),
);
Some(worker.start(cancel_token))
}
pub fn create_hold_expiration_worker(
storage: &Storage,
cancel_token: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
use services::{HoldExpirationConfig, HoldExpirationWorker};
let worker = HoldExpirationWorker::new(
storage.credit_repo.clone(),
storage.credit_hold_repo.clone(),
HoldExpirationConfig::default(),
);
worker.start(cancel_token)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
fn base_config() -> Config {
use crate::config::{
default_access_expiry, default_audience, default_issuer, default_refresh_expiry,
AppleConfig, CookieConfig, CorsConfig, DatabaseConfig, EmailConfig, GoogleConfig,
JwtConfig, NotificationConfig, PrivacyConfig, RateLimitConfig, ServerConfig,
SolanaConfig, SsoConfig, WalletConfig, WebAuthnConfig, WebhookConfig,
};
Config {
server: ServerConfig::default(),
jwt: JwtConfig {
secret: "s".repeat(32),
rsa_private_key_pem: None,
issuer: default_issuer(),
audience: default_audience(),
access_token_expiry: default_access_expiry(),
refresh_token_expiry: default_refresh_expiry(),
},
email: EmailConfig::default(),
google: GoogleConfig {
enabled: false,
client_id: None,
},
apple: AppleConfig {
enabled: false,
client_id: None,
team_id: None,
},
solana: SolanaConfig::default(),
webauthn: WebAuthnConfig::default(),
cors: CorsConfig::default(),
cookie: CookieConfig::default(),
webhook: WebhookConfig::default(),
rate_limit: RateLimitConfig::default(),
database: DatabaseConfig::default(),
notification: NotificationConfig::default(),
sso: SsoConfig::default(),
wallet: WalletConfig::default(),
privacy: PrivacyConfig::default(),
}
}
#[test]
fn test_decode_note_encryption_key_valid() {
let key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
let bytes = decode_note_encryption_key(&key).expect("valid base64 should decode");
assert_eq!(bytes.len(), 32);
assert!(bytes.iter().all(|byte| *byte == 0));
}
#[test]
fn test_decode_note_encryption_key_invalid() {
assert!(decode_note_encryption_key("not-base64").is_err());
}
#[test]
fn test_build_privacy_sidecar_client_requires_api_key() {
let mut config = base_config();
config.privacy.enabled = true;
config.privacy.sidecar_api_key = None;
match build_privacy_sidecar_client(&config) {
Ok(_) => panic!("expected error for missing SIDECAR_API_KEY"),
Err(err) => {
assert!(err.to_string().contains("SIDECAR_API_KEY is required"));
}
}
}
#[test]
fn test_build_privacy_sidecar_client_with_api_key() {
let mut config = base_config();
config.privacy.enabled = true;
config.privacy.sidecar_api_key = Some("test-key".to_string());
assert!(build_privacy_sidecar_client(&config).is_ok());
}
}