//! Paired-device session use cases. use std::sync::Arc; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError}; use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash}; use subtle::ConstantTimeEq; use crate::error::AppError; const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000; /// Input for [`PairDevice`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PairDeviceInput { /// Name supplied by the new device. pub name: String, /// Hash of the freshly issued session token. pub session_token_hash: SessionTokenHash, } /// Output for [`PairDevice`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PairDeviceOutput { /// Created paired device. pub device: PairedDevice, } /// Validates a pairing request and persists the new paired device. pub struct PairDevice { store: Arc, ids: Arc, clock: Arc, } impl PairDevice { /// Builds the use case from injected ports. #[must_use] pub fn new( store: Arc, ids: Arc, clock: Arc, ) -> Self { Self { store, ids, clock } } /// Executes device pairing. /// /// # Errors /// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for /// persistence failures. pub async fn execute(&self, input: PairDeviceInput) -> Result { let now = self.clock.now_millis().max(0) as u64; let device = PairedDevice { device_id: DeviceId::from_uuid(self.ids.new_uuid()), name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?, paired_at_ms: now, last_seen_at_ms: now, session_token_hash: input.session_token_hash, }; let mut devices = self.store.load_devices().await?; devices.push(device.clone()); self.store.save_devices(&devices).await?; Ok(PairDeviceOutput { device }) } } /// Rate-limit key for pairing attempts. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RateLimitKey { /// Resolved origin identity, typically the peer IP after transport-layer proxy handling. pub origin: String, /// Logical route being limited. pub route: String, } /// Pairing-attempt limiter port. #[async_trait] pub trait PairAttemptLimiter: Send + Sync { /// Returns whether a new failed pairing attempt may be processed. async fn check(&self, key: RateLimitKey) -> Result; /// Records one failed pairing attempt for the key and the global bucket. async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>; } /// Result of a rate-limit check. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PairAttemptDecision { /// Attempt can proceed. Allowed, /// Attempt is blocked by one of the buckets. RateLimited, } /// Input for [`AuthenticateSession`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthenticateSessionInput { /// Raw token bytes decoded from the session cookie. pub token_bytes: Vec, } /// Authenticates a persisted device session. pub struct AuthenticateSession { store: Arc, } impl AuthenticateSession { /// Builds the use case from injected ports. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Returns the authenticated device, including its token hash, when valid. /// /// # Errors /// Returns [`AppError::Store`] for persistence failures. pub async fn execute( &self, input: AuthenticateSessionInput, ) -> Result, AppError> { Ok(self.store.authenticate_token(&input.token_bytes).await?) } } /// Input for [`TouchDevice`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TouchDeviceInput { /// Device to mark as seen. pub device_id: DeviceId, } /// Output for [`TouchDevice`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TouchDeviceOutput { /// Whether the store was rewritten. pub updated: bool, } /// Device row exposed to presentation layers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeviceView { /// Persistent device id. pub device_id: DeviceId, /// User-facing name. pub name: String, /// Pairing timestamp as epoch milliseconds. pub paired_at_ms: u64, /// Last successful authenticated access timestamp as epoch milliseconds. pub last_seen_at_ms: u64, /// Whether this row is the authenticated current device. pub is_current_device: bool, } /// Input for [`ListDevices`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListDevicesInput { /// Current device id, when the driving adapter has an authenticated device. pub current_device_id: Option, } /// Output for [`ListDevices`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListDevicesOutput { /// Paired devices. pub devices: Vec, } /// Lists paired devices without exposing transport-sensitive fields. pub struct ListDevices { store: Arc, } impl ListDevices { /// Builds the use case from injected ports. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Executes the listing. /// /// # Errors /// Returns [`AppError::Store`] for persistence failures. pub async fn execute(&self, input: ListDevicesInput) -> Result { let devices = self .store .load_devices() .await? .into_iter() .map(|device| DeviceView { device_id: device.device_id, name: device.name.as_str().to_owned(), paired_at_ms: device.paired_at_ms, last_seen_at_ms: device.last_seen_at_ms, is_current_device: Some(device.device_id) == input.current_device_id, }) .collect(); Ok(ListDevicesOutput { devices }) } } /// Input for [`RenameDevice`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenameDeviceInput { /// Device to rename. pub device_id: DeviceId, /// New user-facing name. pub name: String, } /// Renames a paired device. pub struct RenameDevice { store: Arc, } impl RenameDevice { /// Builds the use case from injected ports. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Executes the rename. /// /// # Errors /// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`] /// when the device does not exist, and [`AppError::Store`] for persistence failures. pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> { let new_name = DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?; let mut devices = self.store.load_devices().await?; let Some(device) = devices .iter_mut() .find(|device| device.device_id == input.device_id) else { return Err(AppError::NotFound("device not found".to_owned())); }; device.name = new_name; self.store.save_devices(&devices).await?; Ok(()) } } /// Input for [`RevokeDevice`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RevokeDeviceInput { /// Device to revoke. pub device_id: DeviceId, } /// Revokes one paired device and publishes a revocation event after persistence. pub struct RevokeDevice { store: Arc, events: Arc, } impl RevokeDevice { /// Builds the use case from injected ports. #[must_use] pub fn new(store: Arc, events: Arc) -> Self { Self { store, events } } /// Executes the revocation. /// /// # Errors /// Returns [`AppError::NotFound`] when the device does not exist and /// [`AppError::Store`] for persistence failures. pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> { if !self.store.remove_device(input.device_id).await? { return Err(AppError::NotFound("device not found".to_owned())); } self.events.publish(DomainEvent::DeviceRevoked { device_id: input.device_id, }); Ok(()) } } /// Revokes every paired device and publishes a global revocation event. pub struct RevokeAllDevices { store: Arc, events: Arc, } impl RevokeAllDevices { /// Builds the use case from injected ports. #[must_use] pub fn new(store: Arc, events: Arc) -> Self { Self { store, events } } /// Executes global revocation. /// /// # Errors /// Returns [`AppError::Store`] for persistence failures. pub async fn execute(&self) -> Result<(), AppError> { self.store.save_devices(&[]).await?; self.events.publish(DomainEvent::AllDevicesRevoked); Ok(()) } } /// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request. pub struct TouchDevice { store: Arc, clock: Arc, } impl TouchDevice { /// Builds the use case from injected ports. #[must_use] pub fn new(store: Arc, clock: Arc) -> Self { Self { store, clock } } /// Executes the touch. /// /// # Errors /// Returns [`AppError::Store`] for persistence failures. pub async fn execute(&self, input: TouchDeviceInput) -> Result { let now = self.clock.now_millis().max(0) as u64; let mut devices = self.store.load_devices().await?; let Some(device) = devices .iter_mut() .find(|device| device.device_id == input.device_id) else { return Ok(TouchDeviceOutput { updated: false }); }; if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS { return Ok(TouchDeviceOutput { updated: false }); } device.last_seen_at_ms = now; self.store.save_devices(&devices).await?; Ok(TouchDeviceOutput { updated: true }) } } /// Normalizes user-entered pairing codes server-side. #[must_use] pub fn normalize_pairing_code(code: &str) -> String { code.chars() .filter(|ch| !ch.is_whitespace() && *ch != '-') .flat_map(char::to_uppercase) .collect() } /// Compares pairing codes after normalization without early-exit byte comparison. #[must_use] pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool { let candidate = normalize_pairing_code(candidate); let expected = normalize_pairing_code(expected); let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into(); bytes_equal && candidate.len() == expected.len() } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use domain::ports::StoreError; use std::sync::Mutex; use uuid::Uuid; #[derive(Default)] struct FakeStore { devices: Mutex>, saves: Mutex, } #[async_trait] impl DeviceSessionStore for FakeStore { async fn load_devices(&self) -> Result, StoreError> { Ok(self.devices.lock().unwrap().clone()) } async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> { *self.devices.lock().unwrap() = devices.to_vec(); *self.saves.lock().unwrap() += 1; Ok(()) } } struct FixedClock(i64); impl domain::ports::Clock for FixedClock { fn now_millis(&self) -> i64 { self.0 } } struct FixedIds; impl domain::ports::IdGenerator for FixedIds { fn new_uuid(&self) -> Uuid { Uuid::from_u128(7) } } #[test] fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() { assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34"); assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34")); assert!(!pairing_codes_equal("AB12CD35", "AB12CD34")); } #[tokio::test] async fn pair_device_validates_name_and_persists_device() { let store = Arc::new(FakeStore::default()); let use_case = PairDevice::new( Arc::clone(&store) as Arc, Arc::new(FixedIds), Arc::new(FixedClock(1234)), ); let output = use_case .execute(PairDeviceInput { name: "Phone".to_owned(), session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), }) .await .unwrap(); assert_eq!(output.device.name.as_str(), "Phone"); assert_eq!(store.load_devices().await.unwrap().len(), 1); } #[tokio::test] async fn pair_device_rejects_empty_name() { let use_case = PairDevice::new( Arc::new(FakeStore::default()), Arc::new(FixedIds), Arc::new(FixedClock(1234)), ); let err = use_case .execute(PairDeviceInput { name: " ".to_owned(), session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); } #[tokio::test] async fn touch_device_is_throttled_to_five_minutes() { let device = PairedDevice { device_id: DeviceId::from_uuid(Uuid::from_u128(7)), name: DeviceName::new("Phone").unwrap(), paired_at_ms: 1000, last_seen_at_ms: 1000, session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), }; let store = Arc::new(FakeStore::default()); store.save_devices(&[device]).await.unwrap(); *store.saves.lock().unwrap() = 0; let use_case = TouchDevice::new( Arc::clone(&store) as Arc, Arc::new(FixedClock((1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64)), ); let output = use_case .execute(TouchDeviceInput { device_id: DeviceId::from_uuid(Uuid::from_u128(7)), }) .await .unwrap(); assert!(!output.updated); assert_eq!(*store.saves.lock().unwrap(), 0); } }