Skip to main content

auths_cli/commands/device/pair/
mod.rs

1//! Device pairing commands.
2//!
3//! Provides commands to initiate and join device pairing sessions
4//! for cross-device identity linking using X25519 ECDH key exchange.
5
6mod common;
7mod join;
8#[cfg(feature = "lan-pairing")]
9mod lan;
10#[cfg(feature = "lan-pairing")]
11mod lan_server;
12#[cfg(feature = "lan-pairing")]
13mod mdns;
14mod offline;
15mod online;
16
17use anyhow::Result;
18use auths_core::config::EnvironmentConfig;
19use clap::Parser;
20
21/// Default registry URL for local development.
22#[cfg(not(feature = "lan-pairing"))]
23const DEFAULT_REGISTRY: &str = "http://localhost:3000";
24
25#[derive(Parser, Debug, Clone)]
26#[command(about = "Link devices to your identity")]
27pub struct PairCommand {
28    /// Join an existing pairing session using a short code
29    #[clap(long, value_name = "CODE")]
30    pub join: Option<String>,
31
32    /// Registry URL for pairing relay (omit for LAN mode)
33    #[clap(long, value_name = "URL")]
34    pub registry: Option<String>,
35
36    /// Don't display QR code (only show short code)
37    #[clap(long)]
38    pub no_qr: bool,
39
40    /// Custom expiry time in seconds (default: 300 = 5 minutes)
41    #[clap(long, value_name = "SECONDS", default_value = "300")]
42    pub expiry: u64,
43
44    /// Skip registry server (offline mode, for testing)
45    #[clap(long)]
46    pub offline: bool,
47
48    /// Capabilities to grant the paired device (comma-separated)
49    #[clap(long, value_delimiter = ',', default_value = "sign_commit")]
50    pub capabilities: Vec<String>,
51
52    /// Disable mDNS advertisement/discovery in LAN mode
53    #[cfg(feature = "lan-pairing")]
54    #[clap(long)]
55    pub no_mdns: bool,
56}
57
58/// Dispatch table:
59///
60/// | Flags                        | Behavior                              |
61/// |------------------------------|---------------------------------------|
62/// | `pair` (no flags)            | LAN mode: start local server, show QR |
63/// | `pair --registry URL`        | Online mode (existing)                |
64/// | `pair --join CODE`           | LAN join: mDNS discover -> join       |
65/// | `pair --join CODE --registry`| Online join (existing)                |
66/// | `pair --offline`             | Offline mode (no network)             |
67pub fn handle_pair(
68    cmd: PairCommand,
69    http_client: &reqwest::Client,
70    env_config: &EnvironmentConfig,
71) -> Result<()> {
72    match (&cmd.join, &cmd.registry, cmd.offline) {
73        // Offline mode takes priority
74        (None, _, true) => {
75            offline::handle_initiate_offline(cmd.no_qr, cmd.expiry, &cmd.capabilities)
76        }
77
78        // Join with explicit registry -> online join
79        (Some(code), Some(registry), _) => {
80            let rt = tokio::runtime::Runtime::new()?;
81            rt.block_on(join::handle_join(code, registry))
82        }
83
84        // Join without registry -> LAN join via mDNS
85        #[cfg(feature = "lan-pairing")]
86        (Some(code), None, _) => {
87            let rt = tokio::runtime::Runtime::new()?;
88            rt.block_on(lan::handle_join_lan(code))
89        }
90
91        // Join without registry and no LAN feature -> use default registry
92        #[cfg(not(feature = "lan-pairing"))]
93        (Some(code), None, _) => {
94            let rt = tokio::runtime::Runtime::new()?;
95            rt.block_on(join::handle_join(code, DEFAULT_REGISTRY))
96        }
97
98        // Initiate with explicit registry -> online mode
99        (None, Some(registry), _) => {
100            let rt = tokio::runtime::Runtime::new()?;
101            rt.block_on(online::handle_initiate_online(
102                http_client,
103                registry,
104                cmd.no_qr,
105                cmd.expiry,
106                &cmd.capabilities,
107                env_config,
108            ))
109        }
110
111        // Initiate without registry -> LAN mode
112        #[cfg(feature = "lan-pairing")]
113        (None, None, false) => {
114            let rt = tokio::runtime::Runtime::new()?;
115            rt.block_on(lan::handle_initiate_lan(
116                cmd.no_qr,
117                cmd.no_mdns,
118                cmd.expiry,
119                &cmd.capabilities,
120                env_config,
121            ))
122        }
123
124        // Initiate without registry and no LAN feature -> use default registry
125        #[cfg(not(feature = "lan-pairing"))]
126        (None, None, false) => {
127            let rt = tokio::runtime::Runtime::new()?;
128            rt.block_on(online::handle_initiate_online(
129                http_client,
130                DEFAULT_REGISTRY,
131                cmd.no_qr,
132                cmd.expiry,
133                &cmd.capabilities,
134                env_config,
135            ))
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use auths_core::pairing::normalize_short_code;
143
144    #[test]
145    fn test_code_normalization() {
146        let codes = vec![
147            ("AB3DEF", "AB3DEF"),
148            ("ab3def", "AB3DEF"),
149            ("AB3 DEF", "AB3DEF"),
150            ("AB3-DEF", "AB3DEF"),
151            ("a b 3 d e f", "AB3DEF"),
152        ];
153
154        for (input, expected) in codes {
155            let normalized = normalize_short_code(input);
156            assert_eq!(normalized, expected, "Input: '{}'", input);
157        }
158    }
159}