Skip to main content

cedros_login/
lib.rs

1#![recursion_limit = "256"]
2
3//! # Cedros Login Server
4//!
5//! Authentication server library with email/password, Google OAuth, and Solana wallet sign-in.
6//!
7//! ## Features
8//!
9//! - **Email/Password**: Traditional registration and login with argon2id password hashing
10//! - **Google OAuth**: Social sign-in via Google ID token verification
11//! - **Solana Wallet**: Sign-in by signing a challenge message with an ed25519 wallet
12//!
13//! ## Usage
14//!
15//! ### Standalone Server
16//!
17//! Run the binary directly:
18//! ```bash
19//! cedros-login-server
20//! ```
21//!
22//! ### Embedded Library
23//!
24//! Integrate into your own Axum application:
25//! ```text
26//! use cedros_login::{router, Config, NoopCallback};
27//! use std::sync::Arc;
28//!
29//! let config = Config::from_env()?;
30//! let callback = Arc::new(NoopCallback);
31//! let auth_router = router(config, callback);
32//!
33//! let app = Router::new()
34//!     .nest("/auth", auth_router)
35//!     .layer(/* your middleware */);
36//! ```
37
38pub 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
51#[cfg(test)]
52pub(crate) mod test_env;
53
54pub use callback::{AuthCallback, AuthCallbackPayload, NoopCallback, ReferralRewardPayload};
55pub use config::{Config, DatabaseConfig, NotificationConfig};
56pub use errors::AppError;
57pub use router::create_router;
58// Re-export NotificationService trait for create_withdrawal_worker
59pub use services::NotificationService;
60pub use services::ReferralPayoutWorker;
61pub use services::{
62    EmailService, InstantLinkEmailData, LogEmailService, NoopEmailService, PasswordResetEmailData,
63    VerificationEmailData,
64};
65#[cfg(feature = "postgres")]
66pub use sqlx::PgPool;
67pub use storage::Storage;
68
69use axum::Router;
70use repositories::{
71    ApiKeyRepository, AuditLogRepository, CredentialRepository, CreditHoldRepository,
72    CreditRefundRequestRepository, CreditRepository, CustomRoleRepository, DepositRepository,
73    DerivedWalletRepository, InviteRepository, LoginAttemptConfig, LoginAttemptRepository,
74    MembershipRepository, NonceRepository, OrgRepository, OutboxRepository, PolicyRepository,
75    PrivacyNoteRepository, ReferralCodeHistoryRepository, ReferralPayoutRepository,
76    SessionRepository, SystemSettingsRepository, TotpRepository, TreasuryConfigRepository,
77    UserRepository, UserWithdrawalLogRepository, VerificationRepository, WalletMaterialRepository,
78    WalletRotationHistoryRepository, WebAuthnRepository,
79};
80use services::{
81    create_wallet_unlock_cache, AppleService, AuditService, CommsService, DepositCreditService,
82    DepositFeeService, EncryptionService, GoogleService, JupiterSwapService, JwtService,
83    MfaAttemptService, NoteEncryptionService, OidcService, PasswordService, PrivacySidecarClient,
84    SanctionsService, SettingsService, SidecarClientConfig, SignupGatingService, SolPriceService,
85    SolanaService, StepUpService, TokenGatingService, TotpService, WalletSigningService,
86    WalletUnlockCache, WebAuthnService,
87};
88use std::sync::Arc;
89use utils::TokenCipher;
90
91fn build_privacy_sidecar_client(config: &Config) -> Result<PrivacySidecarClient, AppError> {
92    let api_key = config
93        .privacy
94        .sidecar_api_key
95        .clone()
96        .ok_or_else(|| AppError::Config("SIDECAR_API_KEY is required".into()))?;
97    PrivacySidecarClient::new(SidecarClientConfig {
98        base_url: config.privacy.sidecar_url.clone(),
99        timeout_ms: config.privacy.sidecar_timeout_ms,
100        api_key,
101    })
102}
103
104fn decode_note_encryption_key(key: &str) -> Result<Vec<u8>, base64::DecodeError> {
105    use base64::{engine::general_purpose::STANDARD, Engine as _};
106
107    STANDARD.decode(key)
108}
109
110fn build_note_encryption_service(
111    key_bytes: &[u8],
112    key_id: &str,
113) -> Result<NoteEncryptionService, AppError> {
114    NoteEncryptionService::new(key_bytes, key_id)
115}
116
117fn preload_settings_cache(settings_service: &Arc<SettingsService>) {
118    if let Ok(handle) = tokio::runtime::Handle::try_current() {
119        if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
120            tokio::task::block_in_place(|| {
121                if let Err(error) = handle.block_on(settings_service.refresh()) {
122                    tracing::warn!(error = %error, "Failed to preload settings cache during router setup");
123                }
124            });
125        }
126    }
127}
128
129/// Auto-generate sidecar secrets in system_settings if they are empty.
130///
131/// Called during startup so a fresh deploy has working defaults.
132/// - `sidecar_api_key`: 32 random bytes, hex-encoded (64 chars)
133/// - `note_encryption_key`: 32 random bytes, base64-encoded
134fn auto_generate_sidecar_secrets(
135    repo: &Arc<dyn SystemSettingsRepository>,
136    encryption: &EncryptionService,
137) {
138    use base64::{engine::general_purpose::STANDARD, Engine as _};
139    use rand::{rngs::OsRng, RngCore};
140
141    let Ok(handle) = tokio::runtime::Handle::try_current() else {
142        return;
143    };
144    if handle.runtime_flavor() != tokio::runtime::RuntimeFlavor::MultiThread {
145        return;
146    }
147
148    tokio::task::block_in_place(|| {
149        handle.block_on(async {
150            let keys_to_generate: Vec<(&str, Box<dyn Fn() -> String>)> = vec![
151                (
152                    "sidecar_api_key",
153                    Box::new(|| {
154                        let mut bytes = [0u8; 32];
155                        OsRng.fill_bytes(&mut bytes);
156                        hex::encode(bytes)
157                    }),
158                ),
159                (
160                    "note_encryption_key",
161                    Box::new(|| {
162                        let mut bytes = [0u8; 32];
163                        OsRng.fill_bytes(&mut bytes);
164                        STANDARD.encode(bytes)
165                    }),
166                ),
167            ];
168
169            for (key, generate) in keys_to_generate {
170                // Check current value
171                match repo.get_by_key(key).await {
172                    Ok(Some(setting)) if !setting.value.is_empty() => {
173                        tracing::debug!(key, "Sidecar secret already set, skipping auto-generation");
174                    }
175                    Ok(_) => {
176                        // Empty or not found — generate and persist
177                        let raw_value = generate();
178                        let encrypted_value = match encryption.encrypt(&raw_value) {
179                            Ok(v) => v,
180                            Err(e) => {
181                                tracing::error!(key, error = %e, "Failed to encrypt auto-generated sidecar secret");
182                                continue;
183                            }
184                        };
185                        let setting = repositories::SystemSetting {
186                            key: key.to_string(),
187                            value: encrypted_value,
188                            category: "privacy".to_string(),
189                            description: None,
190                            is_secret: true,
191                            encryption_version: Some("v1".to_string()),
192                            updated_at: chrono::Utc::now(),
193                            updated_by: None,
194                        };
195                        match repo.upsert_many(vec![setting]).await {
196                            Ok(_) => tracing::info!(key, "Auto-generated sidecar secret"),
197                            Err(e) => tracing::error!(key, error = %e, "Failed to persist auto-generated sidecar secret"),
198                        }
199                    }
200                    Err(e) => {
201                        tracing::warn!(key, error = %e, "Failed to check sidecar secret, skipping auto-generation");
202                    }
203                }
204            }
205        });
206    });
207}
208
209/// Read a sidecar secret from system_settings, decrypting it.
210/// Returns None if the key doesn't exist or is empty.
211fn read_sidecar_secret_sync(settings_service: &Arc<SettingsService>, key: &str) -> Option<String> {
212    let Ok(handle) = tokio::runtime::Handle::try_current() else {
213        return None;
214    };
215    if handle.runtime_flavor() != tokio::runtime::RuntimeFlavor::MultiThread {
216        return None;
217    }
218    tokio::task::block_in_place(|| {
219        handle.block_on(async {
220            match settings_service.get_secret(key).await {
221                Ok(Some(v)) if !v.is_empty() => Some(v),
222                _ => None,
223            }
224        })
225    })
226}
227
228/// Application state shared across all handlers
229pub struct AppState<C: AuthCallback, E: EmailService = LogEmailService> {
230    pub config: Config,
231    pub callback: Arc<C>,
232    pub jwt_service: JwtService,
233    pub password_service: PasswordService,
234    pub google_service: GoogleService,
235    pub apple_service: AppleService,
236    pub solana_service: SolanaService,
237    pub totp_service: TotpService,
238    pub webauthn_service: WebAuthnService,
239    pub oidc_service: OidcService,
240    pub encryption_service: EncryptionService,
241    pub phantom_email: std::marker::PhantomData<E>,
242    pub audit_service: AuditService,
243    pub comms_service: CommsService,
244    pub user_repo: Arc<dyn UserRepository>,
245    pub session_repo: Arc<dyn SessionRepository>,
246    pub nonce_repo: Arc<dyn NonceRepository>,
247    pub verification_repo: Arc<dyn VerificationRepository>,
248    pub org_repo: Arc<dyn OrgRepository>,
249    pub membership_repo: Arc<dyn MembershipRepository>,
250    pub invite_repo: Arc<dyn InviteRepository>,
251    pub audit_repo: Arc<dyn AuditLogRepository>,
252    pub login_attempt_repo: Arc<dyn LoginAttemptRepository>,
253    pub login_attempt_config: LoginAttemptConfig,
254    pub totp_repo: Arc<dyn TotpRepository>,
255    pub custom_role_repo: Arc<dyn CustomRoleRepository>,
256    pub policy_repo: Arc<dyn PolicyRepository>,
257    pub outbox_repo: Arc<dyn OutboxRepository>,
258    pub api_key_repo: Arc<dyn ApiKeyRepository>,
259    pub wallet_material_repo: Arc<dyn WalletMaterialRepository>,
260    pub derived_wallet_repo: Arc<dyn DerivedWalletRepository>,
261    pub wallet_rotation_history_repo: Arc<dyn WalletRotationHistoryRepository>,
262    pub credential_repo: Arc<dyn CredentialRepository>,
263    pub webauthn_repo: Arc<dyn WebAuthnRepository>,
264    pub deposit_repo: Arc<dyn DepositRepository>,
265    pub credit_repo: Arc<dyn CreditRepository>,
266    pub credit_hold_repo: Arc<dyn CreditHoldRepository>,
267    pub credit_refund_request_repo: Arc<dyn CreditRefundRequestRepository>,
268    pub privacy_note_repo: Arc<dyn PrivacyNoteRepository>,
269    /// System settings repository for runtime-configurable values
270    pub system_settings_repo: Arc<dyn SystemSettingsRepository>,
271    /// Treasury configuration repository for micro deposit batching
272    pub treasury_config_repo: Arc<dyn TreasuryConfigRepository>,
273    /// User withdrawal log repository for tracking user-initiated withdrawals
274    pub user_withdrawal_log_repo: Arc<dyn UserWithdrawalLogRepository>,
275    /// Referral payout repository for direct on-chain referral payouts
276    pub referral_payout_repo: Arc<dyn ReferralPayoutRepository>,
277    /// Referral code history repository for preserving retired codes
278    pub referral_code_history_repo: Arc<dyn ReferralCodeHistoryRepository>,
279    /// Settings service with caching for runtime configuration
280    pub settings_service: Arc<SettingsService>,
281    /// SEC-04: Per-user MFA attempt tracking to prevent brute-force
282    pub mfa_attempt_service: MfaAttemptService,
283    pub step_up_service: StepUpService,
284    /// Wallet signing service for server-side transaction signing
285    pub wallet_signing_service: WalletSigningService,
286    /// Wallet unlock cache for session-based credential caching
287    pub wallet_unlock_cache: Arc<WalletUnlockCache>,
288    /// Storage layer for accessing repositories
289    pub storage: Storage,
290    /// Privacy Cash sidecar client (None if privacy not enabled)
291    pub privacy_sidecar_client: Option<Arc<PrivacySidecarClient>>,
292    /// Note encryption service for privacy notes (None if privacy not enabled)
293    pub note_encryption_service: Option<Arc<NoteEncryptionService>>,
294    /// SOL price service for fetching current SOL/USD price
295    pub sol_price_service: Arc<SolPriceService>,
296    /// Jupiter swap service for public deposits (None if company wallet not configured)
297    pub jupiter_swap_service: Option<Arc<JupiterSwapService>>,
298    /// Deposit credit service for calculating credits from deposits
299    pub deposit_credit_service: Arc<DepositCreditService>,
300    /// KYC verification service (None if KYC not configured)
301    pub kyc_service: Option<Arc<services::KycService>>,
302    /// Accredited investor verification service
303    pub accreditation_service: Option<Arc<services::AccreditationService>>,
304    /// Sanctions screening service — always present; disabled state handled internally
305    pub sanctions_service: Arc<SanctionsService>,
306    /// Token gating service — always present; disabled state handled internally
307    pub token_gating_service: Arc<TokenGatingService>,
308    /// Signup gating service — always present; checks disabled state internally
309    pub signup_gating_service: Arc<SignupGatingService>,
310    #[cfg(feature = "postgres")]
311    pub postgres_pool: Option<PgPool>,
312}
313
314/// Create the authentication router with in-memory storage.
315///
316/// This is the simplest entry point, useful for development and testing.
317/// For production with PostgreSQL, use `router_with_storage` instead.
318pub fn router<C: AuthCallback + 'static>(config: Config, callback: Arc<C>) -> Router {
319    router_with_storage(config, callback, Storage::in_memory())
320}
321
322/// Create the authentication router with custom storage backend.
323///
324/// Use this when you need PostgreSQL or a custom storage implementation.
325///
326/// ## Example with PostgreSQL
327///
328/// ```text
329/// use cedros_login::{router_with_storage, Config, Storage, NoopCallback};
330/// use std::sync::Arc;
331///
332/// let config = Config::from_env()?;
333/// let storage = Storage::from_config(&config.database).await?;
334/// let callback = Arc::new(NoopCallback);
335/// let auth_router = router_with_storage(config, callback, storage);
336/// ```
337pub fn router_with_storage<C: AuthCallback + 'static>(
338    config: Config,
339    callback: Arc<C>,
340    storage: Storage,
341) -> Router {
342    // Create SettingsService for runtime configuration (created early so other services can use it)
343    // Note: The cache starts empty. Sync cache accessors used during router setup
344    // (e.g., rate limit configuration) will return None and fall back to config defaults.
345    // The cache is populated on first async access (e.g., deposit handler, withdrawal worker).
346    let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo.clone()));
347    preload_settings_cache(&settings_service);
348
349    // Auto-generate sidecar secrets if empty (needs encryption_service first)
350    let encryption_service = EncryptionService::from_secret(&config.jwt.secret);
351    auto_generate_sidecar_secrets(&storage.system_settings_repo, &encryption_service);
352    // Refresh cache so newly generated secrets are available
353    preload_settings_cache(&settings_service);
354
355    // Create SettingsService with encryption for secret decryption
356    let settings_service = Arc::new(SettingsService::with_encryption(
357        storage.system_settings_repo.clone(),
358        encryption_service.clone(),
359    ));
360    preload_settings_cache(&settings_service);
361
362    let jwt_service = JwtService::new(&config.jwt);
363    let password_service = PasswordService::default();
364    let google_service = GoogleService::new(&config.google);
365    let apple_service = AppleService::new(&config.apple);
366    let solana_service = SolanaService::new(&config.solana);
367    let totp_service = TotpService::new("Cedros");
368    let webauthn_service = WebAuthnService::new(&config.webauthn, settings_service.clone());
369    let audit_service = AuditService::new(storage.audit_repo.clone(), config.server.trust_proxy);
370    let step_up_service = StepUpService::new(storage.session_repo.clone());
371
372    // Create SSO services
373    // SEC-02: Use HTTPS for SSO callback URL when frontend URL is HTTPS
374    let protocol = if config
375        .server
376        .frontend_url
377        .as_ref()
378        .map(|u| u.starts_with("https://"))
379        .unwrap_or(false)
380    {
381        "https"
382    } else {
383        "http"
384    };
385    let sso_callback_url = config.server.sso_callback_url.clone().unwrap_or_else(|| {
386        format!(
387            "{}://{}:{}/auth/sso/callback",
388            protocol, config.server.host, config.server.port
389        )
390    });
391    let oidc_service = OidcService::new(sso_callback_url);
392
393    // Create CommsService for async email/notification delivery
394    let base_url = config
395        .server
396        .frontend_url
397        .clone()
398        .unwrap_or_else(|| "http://localhost:3000".to_string());
399    let token_cipher = TokenCipher::new(&config.jwt.secret);
400    let comms_service = CommsService::new(storage.outbox_repo.clone(), base_url, token_cipher);
401
402    // Create privacy services if enabled
403    // Env vars take precedence; fall back to auto-generated values in system_settings
404    let (privacy_sidecar_client, note_encryption_service) = if config.privacy.enabled {
405        let mut errors = Vec::new();
406
407        // Resolve sidecar API key: env var → system_settings
408        let resolved_api_key = config
409            .privacy
410            .sidecar_api_key
411            .clone()
412            .or_else(|| read_sidecar_secret_sync(&settings_service, "sidecar_api_key"));
413
414        let sidecar = match resolved_api_key {
415            Some(api_key) => match PrivacySidecarClient::new(SidecarClientConfig {
416                base_url: config.privacy.sidecar_url.clone(),
417                timeout_ms: config.privacy.sidecar_timeout_ms,
418                api_key,
419            }) {
420                Ok(s) => Some(Arc::new(s)),
421                Err(e) => {
422                    errors.push(format!("Failed to create privacy sidecar client: {}", e));
423                    None
424                }
425            },
426            None => {
427                errors.push("SIDECAR_API_KEY is required (env var or system_settings)".to_string());
428                None
429            }
430        };
431
432        // Resolve note encryption key: env var → system_settings
433        let resolved_note_key = config
434            .privacy
435            .note_encryption_key
436            .clone()
437            .or_else(|| read_sidecar_secret_sync(&settings_service, "note_encryption_key"));
438
439        let note_encryption = match resolved_note_key.as_deref() {
440            Some(key) => match decode_note_encryption_key(key) {
441                Ok(key_bytes) => match build_note_encryption_service(
442                    &key_bytes,
443                    &config.privacy.note_encryption_key_id,
444                ) {
445                    Ok(n) => Some(Arc::new(n)),
446                    Err(e) => {
447                        errors.push(format!("Failed to create note encryption service: {}", e));
448                        None
449                    }
450                },
451                Err(e) => {
452                    errors.push(format!("Invalid base64 in note_encryption_key: {}", e));
453                    None
454                }
455            },
456            None => {
457                errors.push("note_encryption_key is required when privacy is enabled (env var or system_settings)".to_string());
458                None
459            }
460        };
461
462        // S-04: Fail startup when privacy is enabled but required services can't be created.
463        // Silently disabling would allow the server to accept deposits it cannot process.
464        if !errors.is_empty() {
465            for error in &errors {
466                tracing::error!("{}", error);
467            }
468            panic!(
469                "Privacy is enabled but required services failed to initialize: {}",
470                errors.join("; ")
471            );
472        } else {
473            (sidecar, note_encryption)
474        }
475    } else {
476        (None, None)
477    };
478
479    // Build Jupiter swap service if company wallet is configured (for public deposits)
480    let jupiter_swap_service = config
481        .privacy
482        .company_wallet_address
483        .as_ref()
484        .and_then(|wallet| {
485            match JupiterSwapService::new(
486                wallet.clone(),
487                &config.privacy.company_currency,
488                None, // API key from env could be added later
489            ) {
490                Ok(service) => Some(Arc::new(service)),
491                Err(e) => {
492                    tracing::error!(error = %e, "Failed to create Jupiter swap service, swap features disabled");
493                    None
494                }
495            }
496        });
497
498    // Create SOL price service (shared across deposit services)
499    let sol_price_service = Arc::new(SolPriceService::new());
500
501    // Create deposit fee and credit services
502    let fee_service = Arc::new(DepositFeeService::new(settings_service.clone()));
503    let deposit_credit_service = Arc::new(DepositCreditService::new(
504        sol_price_service.clone(),
505        fee_service,
506        config.privacy.company_currency.clone(),
507    ));
508
509    let state = Arc::new(AppState {
510        config,
511        callback,
512        jwt_service,
513        password_service,
514        google_service,
515        apple_service,
516        solana_service,
517        totp_service,
518        webauthn_service,
519        oidc_service,
520        encryption_service,
521        phantom_email: std::marker::PhantomData::<LogEmailService>,
522        audit_service,
523        comms_service,
524        user_repo: storage.user_repo.clone(),
525        session_repo: storage.session_repo.clone(),
526        nonce_repo: storage.nonce_repo.clone(),
527        verification_repo: storage.verification_repo.clone(),
528        org_repo: storage.org_repo.clone(),
529        membership_repo: storage.membership_repo.clone(),
530        invite_repo: storage.invite_repo.clone(),
531        audit_repo: storage.audit_repo.clone(),
532        login_attempt_repo: storage.login_attempt_repo.clone(),
533        login_attempt_config: LoginAttemptConfig::default(),
534        totp_repo: storage.totp_repo.clone(),
535        custom_role_repo: storage.custom_role_repo.clone(),
536        policy_repo: storage.policy_repo.clone(),
537        outbox_repo: storage.outbox_repo.clone(),
538        api_key_repo: storage.api_key_repo.clone(),
539        wallet_material_repo: storage.wallet_material_repo.clone(),
540        derived_wallet_repo: storage.derived_wallet_repo.clone(),
541        wallet_rotation_history_repo: storage.wallet_rotation_history_repo.clone(),
542        credential_repo: storage.credential_repo.clone(),
543        webauthn_repo: storage.webauthn_repo.clone(),
544        deposit_repo: storage.deposit_repo.clone(),
545        credit_repo: storage.credit_repo.clone(),
546        credit_hold_repo: storage.credit_hold_repo.clone(),
547        credit_refund_request_repo: storage.credit_refund_request_repo.clone(),
548        privacy_note_repo: storage.privacy_note_repo.clone(),
549        system_settings_repo: storage.system_settings_repo.clone(),
550        treasury_config_repo: storage.treasury_config_repo.clone(),
551        user_withdrawal_log_repo: storage.user_withdrawal_log_repo.clone(),
552        referral_payout_repo: storage.referral_payout_repo.clone(),
553        referral_code_history_repo: storage.referral_code_history_repo.clone(),
554        settings_service: settings_service.clone(),
555        mfa_attempt_service: MfaAttemptService::new(),
556        step_up_service,
557        wallet_signing_service: WalletSigningService::new(),
558        wallet_unlock_cache: create_wallet_unlock_cache(),
559        privacy_sidecar_client,
560        note_encryption_service,
561        sol_price_service,
562        jupiter_swap_service,
563        deposit_credit_service,
564        kyc_service: Some(Arc::new(services::KycService::new(
565            storage.kyc_repo.clone(),
566            storage.user_repo.clone(),
567            settings_service.clone(),
568        ))),
569        accreditation_service: Some(Arc::new(services::AccreditationService::new(
570            storage.accreditation_repo.clone(),
571            storage.user_repo.clone(),
572            settings_service.clone(),
573        ))),
574        sanctions_service: Arc::new(SanctionsService::new(settings_service.clone())),
575        token_gating_service: Arc::new(TokenGatingService::new(
576            settings_service.clone(),
577            storage.user_repo.clone(),
578            storage.wallet_material_repo.clone(),
579        )),
580        signup_gating_service: Arc::new(SignupGatingService::new(
581            storage.access_code_repo.clone(),
582            storage.user_repo.clone(),
583            settings_service.clone(),
584        )),
585        #[cfg(feature = "postgres")]
586        postgres_pool: storage.pg_pool.clone(),
587        storage,
588    });
589    create_router(state)
590}
591
592/// Create a withdrawal worker for Privacy Cash deposits.
593///
594/// Returns `Some(JoinHandle)` if privacy is enabled, `None` otherwise.
595/// The worker will poll for completed deposits and withdraw them to the company wallet.
596///
597/// Runtime-tunable settings (poll_interval, batch_size, timeout, retries, percentage,
598/// partial_withdrawal_*) are read from the database via SettingsService.
599pub fn create_withdrawal_worker(
600    config: &Config,
601    storage: &Storage,
602    settings_service: Arc<SettingsService>,
603    notification_service: Arc<dyn services::NotificationService>,
604    cancel_token: tokio_util::sync::CancellationToken,
605) -> Option<tokio::task::JoinHandle<()>> {
606    if !config.privacy.enabled {
607        return None;
608    }
609
610    // Create sidecar client
611    let sidecar = match build_privacy_sidecar_client(config) {
612        Ok(s) => Arc::new(s),
613        Err(e) => {
614            tracing::error!(error = %e, "Failed to create privacy sidecar client for withdrawal worker");
615            return None;
616        }
617    };
618
619    // S-05: Gracefully handle missing key instead of panicking
620    let encryption_key = match config.privacy.note_encryption_key.as_ref() {
621        Some(k) => k,
622        None => {
623            tracing::error!("note_encryption_key is required when privacy is enabled");
624            return None;
625        }
626    };
627    let key_bytes = match decode_note_encryption_key(encryption_key) {
628        Ok(k) => k,
629        Err(e) => {
630            tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
631            return None;
632        }
633    };
634    let note_encryption = match build_note_encryption_service(
635        &key_bytes,
636        &config.privacy.note_encryption_key_id,
637    ) {
638        Ok(s) => Arc::new(s),
639        Err(e) => {
640            tracing::error!(error = %e, "Failed to create note encryption service for withdrawal worker");
641            return None;
642        }
643    };
644
645    // Create withdrawal worker - runtime settings come from SettingsService (DB)
646    // Only company_currency stays in config (env var)
647    use services::{WithdrawalWorker, WithdrawalWorkerConfig};
648    let worker_config = WithdrawalWorkerConfig {
649        company_currency: config.privacy.company_currency.clone(),
650    };
651    let worker = WithdrawalWorker::new(
652        storage.deposit_repo.clone(),
653        storage.withdrawal_history_repo.clone(),
654        sidecar,
655        note_encryption,
656        notification_service,
657        settings_service,
658        worker_config,
659    );
660
661    Some(worker.start(cancel_token))
662}
663
664/// Create a micro batch worker for SOL micro deposits.
665///
666/// Returns `Some(JoinHandle)` if privacy is enabled and treasury is configured,
667/// `None` otherwise. The worker polls for pending micro deposits and batches them
668/// when the accumulated value reaches the threshold ($10).
669///
670/// Runtime-tunable settings (poll_interval, threshold_usd) are read from the database
671/// via SettingsService.
672pub fn create_micro_batch_worker(
673    config: &Config,
674    storage: &Storage,
675    settings_service: Arc<SettingsService>,
676    cancel_token: tokio_util::sync::CancellationToken,
677) -> Option<tokio::task::JoinHandle<()>> {
678    if !config.privacy.enabled {
679        return None;
680    }
681
682    // Create sidecar client
683    let sidecar = match build_privacy_sidecar_client(config) {
684        Ok(s) => Arc::new(s),
685        Err(e) => {
686            tracing::error!(error = %e, "Failed to create privacy sidecar client for micro batch worker");
687            return None;
688        }
689    };
690
691    // S-05: Gracefully handle missing key instead of panicking
692    let encryption_key = match config.privacy.note_encryption_key.as_ref() {
693        Some(k) => k,
694        None => {
695            tracing::error!("note_encryption_key is required when privacy is enabled");
696            return None;
697        }
698    };
699    let key_bytes = match decode_note_encryption_key(encryption_key) {
700        Ok(k) => k,
701        Err(e) => {
702            tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
703            return None;
704        }
705    };
706    let note_encryption = match build_note_encryption_service(
707        &key_bytes,
708        &config.privacy.note_encryption_key_id,
709    ) {
710        Ok(s) => Arc::new(s),
711        Err(e) => {
712            tracing::error!(error = %e, "Failed to create note encryption service for micro batch worker");
713            return None;
714        }
715    };
716
717    // Create sol price service
718    let sol_price_service = Arc::new(services::SolPriceService::new());
719
720    // Create the worker
721    use services::MicroBatchWorker;
722    let worker = MicroBatchWorker::new(
723        storage.deposit_repo.clone(),
724        storage.treasury_config_repo.clone(),
725        sidecar,
726        sol_price_service,
727        note_encryption,
728        settings_service,
729        config.privacy.company_currency.clone(),
730    );
731
732    Some(worker.start(cancel_token))
733}
734
735/// Create a referral payout worker for automated on-chain payouts.
736///
737/// Returns `Some(JoinHandle)` if privacy is enabled, `None` otherwise.
738/// The worker polls for pending referral payouts and processes them
739/// using the treasury wallet.
740///
741/// Runtime-tunable settings (payout_auto_enabled, payout_poll_interval_secs,
742/// payout_batch_size) are read from the database via SettingsService.
743pub fn create_referral_payout_worker(
744    config: &Config,
745    storage: &Storage,
746    settings_service: Arc<SettingsService>,
747    cancel_token: tokio_util::sync::CancellationToken,
748) -> Option<tokio::task::JoinHandle<()>> {
749    if !config.privacy.enabled {
750        return None;
751    }
752
753    let sidecar = match build_privacy_sidecar_client(config) {
754        Ok(s) => Arc::new(s),
755        Err(e) => {
756            tracing::error!(error = %e, "Failed to create sidecar client for referral payout worker");
757            return None;
758        }
759    };
760
761    let encryption_key = match config.privacy.note_encryption_key.as_ref() {
762        Some(k) => k,
763        None => {
764            tracing::error!("note_encryption_key is required for referral payout worker");
765            return None;
766        }
767    };
768    let key_bytes = match decode_note_encryption_key(encryption_key) {
769        Ok(k) => k,
770        Err(e) => {
771            tracing::error!(error = %e, "Invalid base64 in note_encryption_key");
772            return None;
773        }
774    };
775    let note_encryption = match build_note_encryption_service(
776        &key_bytes,
777        &config.privacy.note_encryption_key_id,
778    ) {
779        Ok(s) => Arc::new(s),
780        Err(e) => {
781            tracing::error!(error = %e, "Failed to create note encryption for referral payout worker");
782            return None;
783        }
784    };
785
786    let worker = ReferralPayoutWorker::new(
787        storage.referral_payout_repo.clone(),
788        storage.treasury_config_repo.clone(),
789        sidecar,
790        note_encryption,
791        settings_service,
792    );
793
794    Some(worker.start(cancel_token))
795}
796
797/// Create a hold expiration worker for credit holds.
798///
799/// This worker periodically expires stale credit holds that have exceeded their TTL,
800/// releasing the held credits back to users' available balance.
801///
802/// Returns the JoinHandle for the background task.
803pub fn create_hold_expiration_worker(
804    storage: &Storage,
805    cancel_token: tokio_util::sync::CancellationToken,
806) -> tokio::task::JoinHandle<()> {
807    use services::{HoldExpirationConfig, HoldExpirationWorker};
808
809    let worker = HoldExpirationWorker::new(
810        storage.credit_repo.clone(),
811        storage.credit_hold_repo.clone(),
812        HoldExpirationConfig::default(),
813    );
814
815    worker.start(cancel_token)
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use base64::Engine;
822
823    fn base_config() -> Config {
824        use crate::config::{
825            default_access_expiry, default_audience, default_issuer, default_refresh_expiry,
826            AppleConfig, CookieConfig, CorsConfig, DatabaseConfig, EmailConfig, GoogleConfig,
827            JwtConfig, NotificationConfig, PrivacyConfig, RateLimitConfig, ServerConfig,
828            SolanaConfig, SsoConfig, WalletConfig, WebAuthnConfig, WebhookConfig,
829        };
830
831        Config {
832            server: ServerConfig::default(),
833            jwt: JwtConfig {
834                secret: "s".repeat(32),
835                rsa_private_key_pem: None,
836                issuer: default_issuer(),
837                audience: default_audience(),
838                access_token_expiry: default_access_expiry(),
839                refresh_token_expiry: default_refresh_expiry(),
840            },
841            email: EmailConfig::default(),
842            google: GoogleConfig {
843                enabled: false,
844                client_id: None,
845            },
846            apple: AppleConfig {
847                enabled: false,
848                client_id: None,
849                team_id: None,
850                ..AppleConfig::default()
851            },
852            solana: SolanaConfig::default(),
853            webauthn: WebAuthnConfig::default(),
854            cors: CorsConfig::default(),
855            cookie: CookieConfig::default(),
856            webhook: WebhookConfig::default(),
857            rate_limit: RateLimitConfig::default(),
858            database: DatabaseConfig::default(),
859            notification: NotificationConfig::default(),
860            sso: SsoConfig::default(),
861            wallet: WalletConfig::default(),
862            privacy: PrivacyConfig::default(),
863        }
864    }
865
866    #[test]
867    fn test_decode_note_encryption_key_valid() {
868        let key = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
869        let bytes = decode_note_encryption_key(&key).expect("valid base64 should decode");
870        assert_eq!(bytes.len(), 32);
871        assert!(bytes.iter().all(|byte| *byte == 0));
872    }
873
874    #[test]
875    fn test_decode_note_encryption_key_invalid() {
876        assert!(decode_note_encryption_key("not-base64").is_err());
877    }
878
879    #[test]
880    fn test_build_privacy_sidecar_client_requires_api_key() {
881        let mut config = base_config();
882        config.privacy.enabled = true;
883        config.privacy.sidecar_api_key = None;
884
885        match build_privacy_sidecar_client(&config) {
886            Ok(_) => panic!("expected error for missing SIDECAR_API_KEY"),
887            Err(err) => {
888                assert!(err.to_string().contains("SIDECAR_API_KEY is required"));
889            }
890        }
891    }
892
893    #[test]
894    fn test_build_privacy_sidecar_client_with_api_key() {
895        let mut config = base_config();
896        config.privacy.enabled = true;
897        config.privacy.sidecar_api_key = Some("test-key".to_string());
898
899        assert!(build_privacy_sidecar_client(&config).is_ok());
900    }
901
902    #[test]
903    fn test_preload_settings_cache_populates_cached_values() {
904        let storage = Storage::in_memory();
905        let settings_service = Arc::new(SettingsService::new(storage.system_settings_repo));
906        let runtime = tokio::runtime::Builder::new_multi_thread()
907            .worker_threads(1)
908            .enable_all()
909            .build()
910            .expect("runtime");
911
912        runtime.block_on(async {
913            preload_settings_cache(&settings_service);
914        });
915
916        assert!(settings_service
917            .get_cached_u32_sync("rate_limit_auth")
918            .is_some());
919    }
920}