Skip to main content

auths_sdk/workflows/
status.rs

1//! Status workflow — aggregates identity, device, and agent state for user-friendly reporting.
2
3use crate::result::{DeviceReadiness, NextStep};
4use chrono::{DateTime, Duration, Utc};
5
6/// Status rules for reporting Auths state.
7///
8/// The consumed surface is [`StatusWorkflow::compute_readiness`] (per-device
9/// readiness from expiry/revocation) and [`StatusWorkflow::next_steps_from_readiness`]
10/// (the next-step rules). The CLI (`auths status`) loads identity/devices/agent
11/// itself and calls these — they are the single source of truth for the rules.
12pub struct StatusWorkflow;
13
14impl StatusWorkflow {
15    /// The next-step rules, keyed on the minimal facts they need — identity presence, each device's
16    /// readiness, and whether the signing agent is live. The single source of truth for these rules,
17    /// including the recovery single-point-of-failure signpost, so any presentation layer (the CLI)
18    /// shares them rather than re-deriving its own.
19    ///
20    /// Args:
21    /// * `identity_present`: whether an identity is initialized.
22    /// * `readinesses`: the readiness of each device in the roster.
23    /// * `agent_running`: whether the signing agent is live.
24    pub fn next_steps_from_readiness(
25        identity_present: bool,
26        readinesses: &[DeviceReadiness],
27        agent_running: bool,
28    ) -> Vec<NextStep> {
29        let mut steps = Vec::new();
30
31        // No identity initialized
32        if !identity_present {
33            steps.push(NextStep {
34                summary: "Initialize your identity".to_string(),
35                command: "auths init --profile developer".to_string(),
36            });
37            return steps;
38        }
39
40        // No devices linked
41        if readinesses.is_empty() {
42            steps.push(NextStep {
43                summary: "Link this device to your identity".to_string(),
44                command: "auths pair".to_string(),
45            });
46        }
47
48        // Devices expiring soon
49        let expiring_soon = readinesses
50            .iter()
51            .filter(|r| **r == DeviceReadiness::ExpiringSoon)
52            .count();
53        if expiring_soon > 0 {
54            steps.push(NextStep {
55                summary: format!("{} device(s) expiring soon", expiring_soon),
56                command: "auths device extend".to_string(),
57            });
58        }
59
60        // A single usable device is a recovery single point of failure: if it is lost or
61        // compromised there is no second device to recover the identity from.
62        if Self::needs_recovery_device(readinesses) {
63            steps.push(NextStep {
64                summary: "Add a recovery device — with only one device, losing or compromising it \
65                          leaves no way to recover your identity"
66                    .to_string(),
67                command: "auths pair".to_string(),
68            });
69        }
70
71        // Agent not running
72        if !agent_running {
73            steps.push(NextStep {
74                summary: "Start the authentication agent for signing".to_string(),
75                command: "auths agent start".to_string(),
76            });
77        }
78
79        // Always suggest viewing help for deeper features
80        if steps.is_empty() {
81            steps.push(NextStep {
82                summary: "Explore advanced features".to_string(),
83                command: "auths --help-all".to_string(),
84            });
85        }
86
87        steps
88    }
89
90    /// Determine device readiness given expiration timestamps.
91    pub fn compute_readiness(
92        expires_at: Option<DateTime<Utc>>,
93        revoked_at: Option<DateTime<Utc>>,
94        now: DateTime<Utc>,
95    ) -> DeviceReadiness {
96        if revoked_at.is_some() {
97            return DeviceReadiness::Revoked;
98        }
99
100        match expires_at {
101            Some(exp) if exp < now => DeviceReadiness::Expired,
102            Some(exp) if exp - now < Duration::days(7) => DeviceReadiness::ExpiringSoon,
103            Some(_) => DeviceReadiness::Ok,
104            None => DeviceReadiness::Ok, // No expiry set
105        }
106    }
107
108    /// Whether the identity is a recovery single point of failure: exactly one usable
109    /// (non-revoked, non-expired) device, so a lost or compromised device leaves no second
110    /// device to recover from. Zero devices is a different state (link a device first).
111    ///
112    /// Args:
113    /// * `readinesses`: the readiness of each device in the roster.
114    fn needs_recovery_device(readinesses: &[DeviceReadiness]) -> bool {
115        readinesses
116            .iter()
117            .filter(|r| !matches!(r, DeviceReadiness::Revoked | DeviceReadiness::Expired))
118            .count()
119            == 1
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    #[allow(clippy::disallowed_methods)]
129    fn test_compute_readiness_revoked() {
130        let now = Utc::now();
131        let readiness =
132            StatusWorkflow::compute_readiness(None, Some(now - Duration::hours(1)), now);
133        assert_eq!(readiness, DeviceReadiness::Revoked);
134    }
135
136    #[test]
137    #[allow(clippy::disallowed_methods)]
138    fn test_compute_readiness_expired() {
139        let now = Utc::now();
140        let exp = now - Duration::days(1);
141        let readiness = StatusWorkflow::compute_readiness(Some(exp), None, now);
142        assert_eq!(readiness, DeviceReadiness::Expired);
143    }
144
145    #[test]
146    #[allow(clippy::disallowed_methods)]
147    fn test_compute_readiness_expiring_soon() {
148        let now = Utc::now();
149        let exp = now + Duration::days(3);
150        let readiness = StatusWorkflow::compute_readiness(Some(exp), None, now);
151        assert_eq!(readiness, DeviceReadiness::ExpiringSoon);
152    }
153
154    #[test]
155    #[allow(clippy::disallowed_methods)]
156    fn test_compute_readiness_ok() {
157        let now = Utc::now();
158        let exp = now + Duration::days(30);
159        let readiness = StatusWorkflow::compute_readiness(Some(exp), None, now);
160        assert_eq!(readiness, DeviceReadiness::Ok);
161    }
162
163    #[test]
164    fn test_next_steps_no_identity() {
165        let steps = StatusWorkflow::next_steps_from_readiness(false, &[], false);
166        assert!(!steps.is_empty());
167        assert!(steps[0].command.contains("init"));
168    }
169
170    #[test]
171    fn single_usable_device_is_a_recovery_single_point_of_failure() {
172        // One usable device → no second device to recover from → flagged.
173        assert!(StatusWorkflow::needs_recovery_device(&[
174            DeviceReadiness::Ok
175        ]));
176        // Two usable devices → a recovery path exists → not flagged.
177        assert!(!StatusWorkflow::needs_recovery_device(&[
178            DeviceReadiness::Ok,
179            DeviceReadiness::Ok
180        ]));
181        // A revoked device is not a recovery option: one usable + one revoked is still a
182        // single point of failure.
183        assert!(StatusWorkflow::needs_recovery_device(&[
184            DeviceReadiness::Ok,
185            DeviceReadiness::Revoked
186        ]));
187        // Zero devices is a different state (link a device first), not a recovery nag.
188        assert!(!StatusWorkflow::needs_recovery_device(&[]));
189    }
190
191    #[test]
192    fn single_device_identity_is_told_to_add_a_recovery_device() {
193        let steps = StatusWorkflow::next_steps_from_readiness(true, &[DeviceReadiness::Ok], true);
194        assert!(
195            steps.iter().any(|s| s.summary.contains("recovery device")),
196            "a single-device identity must be told to add a recovery device, got {steps:?}"
197        );
198    }
199}