Skip to main content

microclaw_engine/internal/storage/db/
pairing.rs

1use super::*;
2
3/// A Work client (or any other device) that paired with this Server and
4/// holds a revocable device token. Only the token hash is stored.
5#[derive(Debug, Clone)]
6pub struct PairedDeviceRecord {
7    pub id: i64,
8    pub device_id: String,
9    pub label: String,
10    pub platform: Option<String>,
11    pub prefix: String,
12    pub created_at: String,
13    pub last_seen_at: Option<String>,
14    pub revoked_at: Option<String>,
15}
16
17impl Database {
18    pub fn create_paired_device(
19        &self,
20        device_id: &str,
21        label: &str,
22        platform: Option<&str>,
23        token_hash: &str,
24        prefix: &str,
25    ) -> Result<i64, MicroClawError> {
26        let conn = self.lock_conn();
27        let now = chrono::Utc::now().to_rfc3339();
28        conn.execute(
29            "INSERT INTO paired_devices(device_id, label, platform, token_hash, prefix, created_at)
30             VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
31            params![device_id, label, platform, token_hash, prefix, now],
32        )?;
33        Ok(conn.last_insert_rowid())
34    }
35
36    pub fn list_paired_devices(&self) -> Result<Vec<PairedDeviceRecord>, MicroClawError> {
37        let conn = self.lock_conn();
38        let mut stmt = conn.prepare(
39            "SELECT id, device_id, label, platform, prefix, created_at, last_seen_at, revoked_at
40             FROM paired_devices
41             ORDER BY id DESC",
42        )?;
43        let rows = stmt.query_map([], |row| {
44            Ok(PairedDeviceRecord {
45                id: row.get(0)?,
46                device_id: row.get(1)?,
47                label: row.get(2)?,
48                platform: row.get(3)?,
49                prefix: row.get(4)?,
50                created_at: row.get(5)?,
51                last_seen_at: row.get(6)?,
52                revoked_at: row.get(7)?,
53            })
54        })?;
55        Ok(rows.collect::<Result<Vec<_>, _>>()?)
56    }
57
58    pub fn get_paired_device_by_device_id(
59        &self,
60        device_id: &str,
61    ) -> Result<Option<PairedDeviceRecord>, MicroClawError> {
62        let conn = self.lock_conn();
63        let row = conn
64            .query_row(
65                "SELECT id, device_id, label, platform, prefix, created_at, last_seen_at, revoked_at
66                 FROM paired_devices WHERE device_id = ?1",
67                params![device_id],
68                |row| {
69                    Ok(PairedDeviceRecord {
70                        id: row.get(0)?,
71                        device_id: row.get(1)?,
72                        label: row.get(2)?,
73                        platform: row.get(3)?,
74                        prefix: row.get(4)?,
75                        created_at: row.get(5)?,
76                        last_seen_at: row.get(6)?,
77                        revoked_at: row.get(7)?,
78                    })
79                },
80            )
81            .optional()?;
82        Ok(row)
83    }
84
85    /// Soft-revoke a device. Returns false when no such row exists.
86    pub fn revoke_paired_device(&self, id: i64) -> Result<bool, MicroClawError> {
87        let conn = self.lock_conn();
88        let now = chrono::Utc::now().to_rfc3339();
89        let rows = conn.execute(
90            "UPDATE paired_devices
91             SET revoked_at = COALESCE(revoked_at, ?2)
92             WHERE id = ?1",
93            params![id, now],
94        )?;
95        Ok(rows > 0)
96    }
97
98    /// Resolve a presented token hash to an active device, bumping
99    /// `last_seen_at`. Revoked devices resolve to `None`.
100    pub fn validate_paired_device_token_hash(
101        &self,
102        token_hash: &str,
103    ) -> Result<Option<PairedDeviceRecord>, MicroClawError> {
104        let conn = self.lock_conn();
105        let row = conn
106            .query_row(
107                "SELECT id, device_id, label, platform, prefix, created_at, last_seen_at, revoked_at
108                 FROM paired_devices
109                 WHERE token_hash = ?1 AND revoked_at IS NULL
110                 LIMIT 1",
111                params![token_hash],
112                |row| {
113                    Ok(PairedDeviceRecord {
114                        id: row.get(0)?,
115                        device_id: row.get(1)?,
116                        label: row.get(2)?,
117                        platform: row.get(3)?,
118                        prefix: row.get(4)?,
119                        created_at: row.get(5)?,
120                        last_seen_at: row.get(6)?,
121                        revoked_at: row.get(7)?,
122                    })
123                },
124            )
125            .optional()?;
126        let Some(mut record) = row else {
127            return Ok(None);
128        };
129        let now = chrono::Utc::now().to_rfc3339();
130        let _ = conn.execute(
131            "UPDATE paired_devices SET last_seen_at = ?2 WHERE id = ?1",
132            params![record.id, now],
133        );
134        record.last_seen_at = Some(now);
135        Ok(Some(record))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use crate::internal::storage::db::test_support::*;
142
143    #[test]
144    fn paired_device_lifecycle_create_validate_revoke() {
145        let (db, dir) = test_db();
146        let id = db
147            .create_paired_device(
148                "dev-1",
149                "Everett's MacBook",
150                Some("macos"),
151                "hash-1",
152                "mcd_abc",
153            )
154            .unwrap();
155        assert!(id > 0);
156
157        let active = db.validate_paired_device_token_hash("hash-1").unwrap();
158        let active = active.expect("token resolves while active");
159        assert_eq!(active.device_id, "dev-1");
160        assert_eq!(active.label, "Everett's MacBook");
161        assert_eq!(active.platform.as_deref(), Some("macos"));
162        assert!(
163            active.last_seen_at.is_some(),
164            "validation bumps last_seen_at"
165        );
166        assert!(db
167            .validate_paired_device_token_hash("hash-unknown")
168            .unwrap()
169            .is_none());
170
171        let listed = db.list_paired_devices().unwrap();
172        assert_eq!(listed.len(), 1);
173        assert_eq!(listed[0].prefix, "mcd_abc");
174        assert!(listed[0].revoked_at.is_none());
175
176        assert!(db.revoke_paired_device(id).unwrap());
177        assert!(db
178            .validate_paired_device_token_hash("hash-1")
179            .unwrap()
180            .is_none());
181        let listed = db.list_paired_devices().unwrap();
182        assert!(listed[0].revoked_at.is_some());
183        // Revoking twice keeps the first timestamp and still reports the row.
184        let first = listed[0].revoked_at.clone();
185        assert!(db.revoke_paired_device(id).unwrap());
186        assert_eq!(db.list_paired_devices().unwrap()[0].revoked_at, first);
187        assert!(!db.revoke_paired_device(999).unwrap());
188
189        let by_id = db.get_paired_device_by_device_id("dev-1").unwrap().unwrap();
190        assert_eq!(by_id.id, id);
191        assert!(db.get_paired_device_by_device_id("nope").unwrap().is_none());
192        cleanup(&dir);
193    }
194
195    #[test]
196    fn paired_device_token_hash_and_device_id_are_unique() {
197        let (db, dir) = test_db();
198        db.create_paired_device("dev-1", "a", None, "hash-1", "p")
199            .unwrap();
200        assert!(db
201            .create_paired_device("dev-2", "b", None, "hash-1", "p")
202            .is_err());
203        assert!(db
204            .create_paired_device("dev-1", "c", None, "hash-2", "p")
205            .is_err());
206        cleanup(&dir);
207    }
208}