1#![recursion_limit = "256"]
2
3pub mod callback;
39pub mod config;
40pub mod errors;
41pub mod handlers;
42pub mod middleware;
43pub mod models;
44pub mod repositories;
45pub mod services;
46pub mod storage;
47pub mod utils;
48
49mod router;
50
51pub use callback::{AuthCallback, AuthCallbackPayload, NoopCallback};
52pub use config::{Config, DatabaseConfig, NotificationConfig};
53pub use errors::AppError;
54pub use router::create_router;
55pub use services::NotificationService;
57pub use services::{
58 EmailService, InstantLinkEmailData, LogEmailService, NoopEmailService, PasswordResetEmailData,
59 VerificationEmailData,
60};
61#[cfg(feature = "postgres")]
62pub use sqlx::PgPool;
63pub use storage::Storage;
64
65use axum::Router;
66use repositories::{
67 ApiKeyRepository, AuditLogRepository, CredentialRepository, CreditHoldRepository,
68 CreditRefundRequestRepository, CreditRepository, CustomRoleRepository, DepositRepository,
69 DerivedWalletRepository, InviteRepository, LoginAttemptConfig, LoginAttemptRepository,
70 MembershipRepository, NonceRepository, OrgRepository, OutboxRepository, PolicyRepository,
71 PrivacyNoteRepository,
72 SessionRepository, SystemSettingsRepository, TotpRepository, TreasuryConfigRepository,
73 UserRepository, UserWithdrawalLogRepository, VerificationRepository, WalletMaterialRepository,
74 WalletRotationHistoryRepository, WebAuthnRepository,
75};
76use services::{
77 create_wallet_unlock_cache, AppleService, AuditService, CommsService, DepositCreditService,
78 DepositFeeService, EncryptionService, GoogleService, JupiterSwapService, JwtService,
79 MfaAttemptService, NoteEncryptionService, OidcService, PasswordService, PrivacySidecarClient,
80 SettingsService, SidecarClientConfig, SolPriceService, SolanaService, StepUpService,
81 TotpService, WalletSigningService, WalletUnlockCache, WebAuthnService,
82};
83use std::sync::Arc;
84use utils::TokenCipher;
85
86fn build_privacy_sidecar_client(config: &Config) -> Result<PrivacySidecarClient, AppError> {
87 let api_key = config
88 .privacy
89 .sidecar_api_key
90 .clone()
91 .ok_or_else(|| AppError::Config("SIDECAR_API_KEY is required".into()))?;
92 PrivacySidecarClient::new(SidecarClientConfig {
93 base_url: config.privacy.sidecar_url.clone(),
94 timeout_ms: config.privacy.sidecar_timeout_ms,
95 api_key,
96 })
97}
98
99fn decode_note_encryption_key(key: &str) -> Result<Vec<u8>, base64::DecodeError> {
100 use base64::{engine::general_purpose::STANDARD, Engine as _};
101
102 STANDARD.decode(key)
103}
104
105fn build_note_encryption_service(
106 key_bytes: &[u8],
107 key_id: &str,
108) -> Result<NoteEncryptionService, AppError> {
109 NoteEncryptionService::new(key_bytes, key_id)
110}
111
112fn preload_settings_cache(settings_service: &Arc<SettingsService>) {
113 if let Ok(handle) = tokio::runtime::Handle::try_current() {
114 if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
115 tokio::task::block_in_place(|| {
116 if let Err(error) = handle.block_on(settings_service.refresh()) {
117 tracing::warn!(error = %error, "Failed to preload settings cache during router setup");
118 }
119 });
120 }
121 }
122}
123
124pub struct AppState<C: AuthCallback, E: EmailService = LogEmailService> {
126 pub config: Config,
127 pub callback: Arc<C>,
128 pub jwt_service: JwtService,
129 pub password_service: PasswordService,
130 pub google_service: GoogleService,
131 pub apple_service: AppleService,
132 pub solana_service: SolanaService,
133 pub totp_service: TotpService,
134 pub webauthn_service: WebAuthnService,
135 pub oidc_service: OidcService,
136 pub encryption_service: EncryptionService,
137 pub phantom_email: std::marker::PhantomData<E>,
138 pub audit_service: AuditService,
139 pub comms_service: CommsService,
140 pub user_repo: Arc<dyn UserRepository>,
141 pub session_repo: Arc<dyn SessionRepository>,
142 pub nonce_repo: Arc<dyn NonceRepository>,
143 pub verification_repo: Arc<dyn VerificationRepository>,
144 pub org_repo: Arc<dyn OrgRepository>,
145 pub membership_repo: Arc<dyn MembershipRepository>,
146 pub invite_repo: Arc<dyn InviteRepository>,
147 pub audit_repo: Arc<dyn AuditLogRepository>,
148 pub login_attempt_repo: Arc<dyn LoginAttemptRepository>,
149 pub login_attempt_config: LoginAttemptConfig,
150 pub totp_repo: Arc<dyn TotpRepository>,
151 pub custom_role_repo: Arc<dyn CustomRoleRepository>,
152 pub policy_repo: Arc<dyn PolicyRepository>,
153 pub outbox_repo: Arc<dyn OutboxRepository>,
154 pub api_key_repo: Arc<dyn ApiKeyRepository>,
155 pub wallet_material_repo: Arc<dyn WalletMaterialRepository>,
156 pub derived_wallet_repo: Arc<dyn DerivedWalletRepository>,
157 pub wallet_rotation_history_repo: Arc<dyn WalletRotationHistoryRepository>,
158 pub credential_repo: Arc<dyn CredentialRepository>,
159 pub webauthn_repo: Arc<dyn WebAuthnRepository>,
160 pub deposit_repo: Arc<dyn DepositRepository>,
161 pub credit_repo: Arc<dyn CreditRepository>,
162 pub credit_hold_repo: Arc<dyn CreditHoldRepository>,
163 pub credit_refund_request_repo: Arc<dyn CreditRefundRequestRepository>,
164 pub privacy_note_repo: Arc<dyn PrivacyNoteRepository>,
165 pub system_settings_repo: Arc<dyn SystemSettingsRepository>,
167 pub treasury_config_repo: Arc<dyn TreasuryConfigRepository>,
169 pub user_withdrawal_log_repo: Arc<dyn UserWithdrawalLogRepository>,
171 pub settings_service: Arc<SettingsService>,
173 pub mfa_attempt_service: MfaAttemptService,
175 pub step_up_service: StepUpService,
176 pub wallet_signing_service: WalletSigningService,
178 pub wallet_unlock_cache: Arc<WalletUnlockCache>,
180 pub storage: Storage,
182 pub privacy_sidecar_client: Option<Arc<PrivacySidecarClient>>,
184 pub note_encryption_service: Option<Arc<NoteEncryptionService>>,
186 pub sol_price_service: Arc<SolPriceService>,
188 pub jupiter_swap_service: Option<Arc<JupiterSwapService>>,
190 pub deposit_credit_service: Arc<DepositCreditService>,
192 #[cfg(feature = "postgres")]
193 pub postgres_pool: Option<PgPool>,
194}
195
196pub fn router<C: AuthCallback + 'static>(config: Config, callback: Arc<C>) -> Router {
201 router_with_storage(config, callback, Storage::in_memory())
202}
203
204pub fn router_with_storage<C: AuthCallback + 'static>(
220 config: Config,
221 callback: Arc<C>,
222 storage: Storage,
223) -> Router {
224 let jwt_service = JwtService::new(&config.jwt);
225 let password_service = PasswordService::default();
226 let google_service = GoogleService::new(&config.google);
227 let apple_service = AppleService::new(&config.apple);
228 let solana_service = SolanaService::new(&config.solana, "Cedros Login".to_string());
229 let totp_service = TotpService::new("Cedros");
230 let webauthn_service = WebAuthnService::new(&config.webauthn);
231 let audit_service = AuditService::new(storage.audit_repo.clone(), config.server.trust_proxy);
232 let step_up_service = StepUpService::new(storage.session_repo.clone());
233
234 let protocol = if config
237 .server
238 .frontend_url
239 .as_ref()
240 .map(|u| u.starts_with("https://"))
241 .unwrap_or(false)
242 {
243 "https"
244 } else {
245 "http"
246 };
247 let sso_callback_url = config.server.sso_callback_url.clone().unwrap_or_else(|| {
248 format!(
249 "{}://{}:{}/auth/sso/callback",
250 protocol, config.server.host, config.server.port
251 )
252 });
253 let oidc_service = OidcService::new(sso_callback_url);
254 let encryption_service = EncryptionService::from_secret(&config.jwt.secret);
255
256 let base_url = config
258 .server
259 .frontend_url
260 .clone()
261 .unwrap_or_else(|| "http://localhost:3000".to_string());
262 let token_cipher = TokenCipher::new(&config.jwt.secret);
263 let comms_service = CommsService::new(storage.outbox_repo.clone(), base_url, token_cipher);
264
265 let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo.clone()));
270 preload_settings_cache(&settings_service);
271
272 let (privacy_sidecar_client, note_encryption_service) = if config.privacy.enabled {
274 let mut errors = Vec::new();
275
276 let sidecar = match build_privacy_sidecar_client(&config) {
277 Ok(s) => Some(Arc::new(s)),
278 Err(e) => {
279 errors.push(format!("Failed to create privacy sidecar client: {}", e));
280 None
281 }
282 };
283
284 let note_encryption = match config.privacy.note_encryption_key.as_ref() {
285 Some(key) => match decode_note_encryption_key(key) {
286 Ok(key_bytes) => match build_note_encryption_service(
287 &key_bytes,
288 &config.privacy.note_encryption_key_id,
289 ) {
290 Ok(n) => Some(Arc::new(n)),
291 Err(e) => {
292 errors.push(format!("Failed to create note encryption service: {}", e));
293 None
294 }
295 },
296 Err(e) => {
297 errors.push(format!("Invalid base64 in note_encryption_key: {}", e));
298 None
299 }
300 },
301 None => {
302 errors.push("note_encryption_key is required when privacy is enabled".to_string());
303 None
304 }
305 };
306
307 if !errors.is_empty() {
310 for error in &errors {
311 tracing::error!("{}", error);
312 }
313 panic!(
314 "Privacy is enabled but required services failed to initialize: {}",
315 errors.join("; ")
316 );
317 } else {
318 (sidecar, note_encryption)
319 }
320 } else {
321 (None, None)
322 };
323
324 let jupiter_swap_service = config
326 .privacy
327 .company_wallet_address
328 .as_ref()
329 .and_then(|wallet| {
330 match JupiterSwapService::new(
331 wallet.clone(),
332 &config.privacy.company_currency,
333 None, ) {
335 Ok(service) => Some(Arc::new(service)),
336 Err(e) => {
337 tracing::error!(error = %e, "Failed to create Jupiter swap service, swap features disabled");
338 None
339 }
340 }
341 });
342
343 let sol_price_service = Arc::new(SolPriceService::new());
345
346 let fee_service = Arc::new(DepositFeeService::new(settings_service.clone()));
348 let deposit_credit_service = Arc::new(DepositCreditService::new(
349 sol_price_service.clone(),
350 fee_service,
351 config.privacy.company_currency.clone(),
352 ));
353
354 let state = Arc::new(AppState {
355 config,
356 callback,
357 jwt_service,
358 password_service,
359 google_service,
360 apple_service,
361 solana_service,
362 totp_service,
363 webauthn_service,
364 oidc_service,
365 encryption_service,
366 phantom_email: std::marker::PhantomData::<LogEmailService>,
367 audit_service,
368 comms_service,
369 user_repo: storage.user_repo.clone(),
370 session_repo: storage.session_repo.clone(),
371 nonce_repo: storage.nonce_repo.clone(),
372 verification_repo: storage.verification_repo.clone(),
373 org_repo: storage.org_repo.clone(),
374 membership_repo: storage.membership_repo.clone(),
375 invite_repo: storage.invite_repo.clone(),
376 audit_repo: storage.audit_repo.clone(),
377 login_attempt_repo: storage.login_attempt_repo.clone(),
378 login_attempt_config: LoginAttemptConfig::default(),
379 totp_repo: storage.totp_repo.clone(),
380 custom_role_repo: storage.custom_role_repo.clone(),
381 policy_repo: storage.policy_repo.clone(),
382 outbox_repo: storage.outbox_repo.clone(),
383 api_key_repo: storage.api_key_repo.clone(),
384 wallet_material_repo: storage.wallet_material_repo.clone(),
385 derived_wallet_repo: storage.derived_wallet_repo.clone(),
386 wallet_rotation_history_repo: storage.wallet_rotation_history_repo.clone(),
387 credential_repo: storage.credential_repo.clone(),
388 webauthn_repo: storage.webauthn_repo.clone(),
389 deposit_repo: storage.deposit_repo.clone(),
390 credit_repo: storage.credit_repo.clone(),
391 credit_hold_repo: storage.credit_hold_repo.clone(),
392 credit_refund_request_repo: storage.credit_refund_request_repo.clone(),
393 privacy_note_repo: storage.privacy_note_repo.clone(),
394 system_settings_repo: storage.system_settings_repo.clone(),
395 treasury_config_repo: storage.treasury_config_repo.clone(),
396 user_withdrawal_log_repo: storage.user_withdrawal_log_repo.clone(),
397 settings_service: settings_service.clone(),
398 mfa_attempt_service: MfaAttemptService::new(),
399 step_up_service,
400 wallet_signing_service: WalletSigningService::new(),
401 wallet_unlock_cache: create_wallet_unlock_cache(),
402 privacy_sidecar_client,
403 note_encryption_service,
404 sol_price_service,
405 jupiter_swap_service,
406 deposit_credit_service,
407 #[cfg(feature = "postgres")]
408 postgres_pool: storage.pg_pool.clone(),
409 storage,
410 });
411 create_router(state)
412}
413
414pub fn create_withdrawal_worker(
422 config: &Config,
423 storage: &Storage,
424 settings_service: Arc<SettingsService>,
425 notification_service: Arc<dyn services::NotificationService>,
426 cancel_token: tokio_util::sync::CancellationToken,
427) -> Option<tokio::task::JoinHandle<()>> {
428 if !config.privacy.enabled {
429 return None;
430 }
431
432 let sidecar = match build_privacy_sidecar_client(config) {
434 Ok(s) => Arc::new(s),
435 Err(e) => {
436 tracing::error!(error = %e, "Failed to create privacy sidecar client for withdrawal worker");
437 return None;
438 }
439 };
440
441 let encryption_key = match config.privacy.note_encryption_key.as_ref() {
443 Some(k) => k,
444 None => {
445 tracing::error!("note_encryption_key is required when privacy is enabled");
446 return None;
447 }
448 };
449 let key_bytes = match decode_note_encryption_key(encryption_key) {
450 Ok(k) => k,
451 Err(e) => {
452 tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
453 return None;
454 }
455 };
456 let note_encryption = match build_note_encryption_service(
457 &key_bytes,
458 &config.privacy.note_encryption_key_id,
459 ) {
460 Ok(s) => Arc::new(s),
461 Err(e) => {
462 tracing::error!(error = %e, "Failed to create note encryption service for withdrawal worker");
463 return None;
464 }
465 };
466
467 use services::{WithdrawalWorker, WithdrawalWorkerConfig};
470 let worker_config = WithdrawalWorkerConfig {
471 company_currency: config.privacy.company_currency.clone(),
472 };
473 let worker = WithdrawalWorker::new(
474 storage.deposit_repo.clone(),
475 storage.withdrawal_history_repo.clone(),
476 sidecar,
477 note_encryption,
478 notification_service,
479 settings_service,
480 worker_config,
481 );
482
483 Some(worker.start(cancel_token))
484}
485
486pub fn create_micro_batch_worker(
495 config: &Config,
496 storage: &Storage,
497 settings_service: Arc<SettingsService>,
498 cancel_token: tokio_util::sync::CancellationToken,
499) -> Option<tokio::task::JoinHandle<()>> {
500 if !config.privacy.enabled {
501 return None;
502 }
503
504 let sidecar = match build_privacy_sidecar_client(config) {
506 Ok(s) => Arc::new(s),
507 Err(e) => {
508 tracing::error!(error = %e, "Failed to create privacy sidecar client for micro batch worker");
509 return None;
510 }
511 };
512
513 let encryption_key = match config.privacy.note_encryption_key.as_ref() {
515 Some(k) => k,
516 None => {
517 tracing::error!("note_encryption_key is required when privacy is enabled");
518 return None;
519 }
520 };
521 let key_bytes = match decode_note_encryption_key(encryption_key) {
522 Ok(k) => k,
523 Err(e) => {
524 tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
525 return None;
526 }
527 };
528 let note_encryption = match build_note_encryption_service(
529 &key_bytes,
530 &config.privacy.note_encryption_key_id,
531 ) {
532 Ok(s) => Arc::new(s),
533 Err(e) => {
534 tracing::error!(error = %e, "Failed to create note encryption service for micro batch worker");
535 return None;
536 }
537 };
538
539 let sol_price_service = Arc::new(services::SolPriceService::new());
541
542 use services::MicroBatchWorker;
544 let worker = MicroBatchWorker::new(
545 storage.deposit_repo.clone(),
546 storage.treasury_config_repo.clone(),
547 sidecar,
548 sol_price_service,
549 note_encryption,
550 settings_service,
551 config.privacy.company_currency.clone(),
552 );
553
554 Some(worker.start(cancel_token))
555}
556
557pub fn create_hold_expiration_worker(
564 storage: &Storage,
565 cancel_token: tokio_util::sync::CancellationToken,
566) -> tokio::task::JoinHandle<()> {
567 use services::{HoldExpirationConfig, HoldExpirationWorker};
568
569 let worker = HoldExpirationWorker::new(
570 storage.credit_repo.clone(),
571 storage.credit_hold_repo.clone(),
572 HoldExpirationConfig::default(),
573 );
574
575 worker.start(cancel_token)
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use base64::Engine;
582
583 fn base_config() -> Config {
584 use crate::config::{
585 default_access_expiry, default_audience, default_issuer, default_refresh_expiry,
586 AppleConfig, CookieConfig, CorsConfig, DatabaseConfig, EmailConfig, GoogleConfig,
587 JwtConfig, NotificationConfig, PrivacyConfig, RateLimitConfig, ServerConfig,
588 SolanaConfig, SsoConfig, WalletConfig, WebAuthnConfig, WebhookConfig,
589 };
590
591 Config {
592 server: ServerConfig::default(),
593 jwt: JwtConfig {
594 secret: "s".repeat(32),
595 rsa_private_key_pem: None,
596 issuer: default_issuer(),
597 audience: default_audience(),
598 access_token_expiry: default_access_expiry(),
599 refresh_token_expiry: default_refresh_expiry(),
600 },
601 email: EmailConfig::default(),
602 google: GoogleConfig {
603 enabled: false,
604 client_id: None,
605 },
606 apple: AppleConfig {
607 enabled: false,
608 client_id: None,
609 team_id: None,
610 },
611 solana: SolanaConfig::default(),
612 webauthn: WebAuthnConfig::default(),
613 cors: CorsConfig::default(),
614 cookie: CookieConfig::default(),
615 webhook: WebhookConfig::default(),
616 rate_limit: RateLimitConfig::default(),
617 database: DatabaseConfig::default(),
618 notification: NotificationConfig::default(),
619 sso: SsoConfig::default(),
620 wallet: WalletConfig::default(),
621 privacy: PrivacyConfig::default(),
622 }
623 }
624
625 #[test]
626 fn test_decode_note_encryption_key_valid() {
627 let key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
628 let bytes = decode_note_encryption_key(&key).expect("valid base64 should decode");
629 assert_eq!(bytes.len(), 32);
630 assert!(bytes.iter().all(|byte| *byte == 0));
631 }
632
633 #[test]
634 fn test_decode_note_encryption_key_invalid() {
635 assert!(decode_note_encryption_key("not-base64").is_err());
636 }
637
638 #[test]
639 fn test_build_privacy_sidecar_client_requires_api_key() {
640 let mut config = base_config();
641 config.privacy.enabled = true;
642 config.privacy.sidecar_api_key = None;
643
644 match build_privacy_sidecar_client(&config) {
645 Ok(_) => panic!("expected error for missing SIDECAR_API_KEY"),
646 Err(err) => {
647 assert!(err.to_string().contains("SIDECAR_API_KEY is required"));
648 }
649 }
650 }
651
652 #[test]
653 fn test_build_privacy_sidecar_client_with_api_key() {
654 let mut config = base_config();
655 config.privacy.enabled = true;
656 config.privacy.sidecar_api_key = Some("test-key".to_string());
657
658 assert!(build_privacy_sidecar_client(&config).is_ok());
659 }
660
661 #[test]
662 fn test_preload_settings_cache_populates_cached_values() {
663 let storage = Storage::in_memory();
664 let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo));
665 let runtime = tokio::runtime::Builder::new_multi_thread()
666 .worker_threads(1)
667 .enable_all()
668 .build()
669 .expect("runtime");
670
671 runtime.block_on(async {
672 preload_settings_cache(&settings_service);
673 });
674
675 assert!(settings_service
676 .get_cached_u32_sync("rate_limit_auth")
677 .is_some());
678 }
679}