microclaw_engine/internal/storage/db/
pairing.rs1use super::*;
2
3#[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 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 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 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}