Skip to main content

ryu_hardware/
store.rs

1//! Device registry (SQLite) — paired Ryu hardware and their tokens.
2//!
3//! Persists the device records backing the REST surface in PROTOCOL.md §6 and
4//! the Bearer-token auth on the WS upgrade (§2). This is the system of record for
5//! "which devices are paired to this node", extending the connections/presence
6//! model with durable, per-device, revocable tokens.
7//!
8//! Placement (Core vs Gateway): the device registry + token lifecycle decide
9//! *which device is allowed to drive this node and what it runs*, so this is
10//! Core. It mirrors [`crate::meetings::store`]: one `rusqlite` connection behind
11//! an `Arc<Mutex<…>>`, opened under `crate::paths::ryu_dir()`.
12//!
13//! The **raw** token is shown to the app exactly once (at pairing); only its
14//! SHA-256 hash is persisted here. A device authenticates the WS upgrade by
15//! presenting the raw token, which is re-hashed and compared against the row.
16
17use anyhow::{Context, Result};
18use rusqlite::{params, Connection, OptionalExtension};
19use std::path::PathBuf;
20use std::sync::Arc;
21use tokio::sync::Mutex;
22
23use super::protocol::DeviceType;
24
25/// A paired device row. The `token_hash` is stored, never the raw token.
26#[derive(Clone, Debug)]
27pub struct DeviceRecord {
28    pub device_id: String,
29    pub device_type: DeviceType,
30    pub name: String,
31    /// Lowercase hex SHA-256 of the Bearer token (raw token shown once at pairing).
32    pub token_hash: String,
33    /// Epoch ms of last WS activity, or `None` if never connected.
34    pub last_seen: Option<i64>,
35    /// Latest reported battery percent, or `None`.
36    pub battery_pct: Option<i32>,
37    /// Free-form per-device prefs (JSON), e.g. wake word, ambient on/off.
38    pub prefs: serde_json::Value,
39    /// The long-running ambient meeting this device resumes on reconnect, so a
40    /// reconnecting ambient device feeds the SAME meeting rather than spawning a
41    /// fresh one each `hello` (PROTOCOL.md §4.2). `None` until first opened.
42    pub ambient_meeting_id: Option<String>,
43    pub created_at: i64,
44}
45
46/// Hash a raw Bearer token to the form stored in the registry. The token is a
47/// 256-bit random secret (see [`super::pairing::generate_device_token`]); a plain
48/// SHA-256 is sufficient for a high-entropy secret (no need for a slow KDF, which
49/// only matters for low-entropy passwords).
50pub fn hash_token(raw: &str) -> String {
51    use sha2::{Digest, Sha256};
52    let digest = Sha256::digest(raw.as_bytes());
53    hex::encode(digest)
54}
55
56fn now_ms() -> i64 {
57    chrono::Utc::now().timestamp_millis()
58}
59
60/// Handle to the device registry table. Cheaply cloneable (wraps an `Arc`).
61#[derive(Clone)]
62pub struct DeviceStore {
63    conn: Arc<Mutex<Connection>>,
64}
65
66impl DeviceStore {
67    /// Open (or create) the store at a specific path and run migrations. The host
68    /// computes the path (`~/.ryu/hardware.db`); this crate never reaches Core's
69    /// `paths` module, so it has zero dependency on `apps/core`.
70    pub fn open(path: PathBuf) -> Result<Self> {
71        if let Some(parent) = path.parent() {
72            std::fs::create_dir_all(parent)
73                .with_context(|| format!("creating db dir {}", parent.display()))?;
74        }
75        let conn = Connection::open(&path)
76            .with_context(|| format!("opening hardware db {}", path.display()))?;
77        Self::init_schema(&conn)?;
78        Ok(Self {
79            conn: Arc::new(Mutex::new(conn)),
80        })
81    }
82
83    fn init_schema(conn: &Connection) -> Result<()> {
84        conn.execute_batch(
85            "PRAGMA journal_mode = WAL;
86             CREATE TABLE IF NOT EXISTS devices (
87                 device_id          TEXT PRIMARY KEY,
88                 device_type        TEXT NOT NULL,
89                 name               TEXT NOT NULL,
90                 token_hash         TEXT NOT NULL,
91                 last_seen          INTEGER,
92                 battery_pct        INTEGER,
93                 prefs              TEXT NOT NULL DEFAULT '{}',
94                 ambient_meeting_id TEXT,
95                 created_at         INTEGER NOT NULL
96             );",
97        )
98        .context("initializing hardware schema")?;
99        Ok(())
100    }
101
102    fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<DeviceRecord> {
103        let device_type_str: String = row.get(1)?;
104        let prefs_str: String = row.get(6)?;
105        Ok(DeviceRecord {
106            device_id: row.get(0)?,
107            device_type: super::protocol::parse_device_type(&device_type_str)
108                .unwrap_or(DeviceType::Necklace),
109            name: row.get(2)?,
110            token_hash: row.get(3)?,
111            last_seen: row.get(4)?,
112            battery_pct: row.get(5)?,
113            prefs: serde_json::from_str(&prefs_str).unwrap_or(serde_json::Value::Null),
114            ambient_meeting_id: row.get(7)?,
115            created_at: row.get(8)?,
116        })
117    }
118
119    /// Insert a freshly paired device (or replace one with the same id — a
120    /// re-pair rotates the token), returning the stored record.
121    pub async fn insert(&self, record: DeviceRecord) -> Result<DeviceRecord> {
122        let device_type = super::protocol::device_type_str(record.device_type).to_string();
123        let prefs = serde_json::to_string(&record.prefs).unwrap_or_else(|_| "{}".to_string());
124        let conn = self.conn.lock().await;
125        conn.execute(
126            "INSERT INTO devices
127                 (device_id, device_type, name, token_hash, last_seen, battery_pct, prefs, ambient_meeting_id, created_at)
128             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
129             ON CONFLICT(device_id) DO UPDATE SET
130                 device_type = ?2, name = ?3, token_hash = ?4, prefs = ?7",
131            params![
132                record.device_id,
133                device_type,
134                record.name,
135                record.token_hash,
136                record.last_seen,
137                record.battery_pct,
138                prefs,
139                record.ambient_meeting_id,
140                record.created_at,
141            ],
142        )
143        .context("inserting device")?;
144        Ok(record)
145    }
146
147    /// Look up a device by id.
148    pub async fn get(&self, device_id: &str) -> Result<Option<DeviceRecord>> {
149        let conn = self.conn.lock().await;
150        conn.query_row(
151            "SELECT device_id, device_type, name, token_hash, last_seen, battery_pct, prefs, ambient_meeting_id, created_at
152             FROM devices WHERE device_id = ?1",
153            params![device_id],
154            Self::row_to_record,
155        )
156        .optional()
157        .context("reading device")
158    }
159
160    /// List all paired devices, newest first (drives `GET /api/hardware/devices`).
161    pub async fn list(&self) -> Result<Vec<DeviceRecord>> {
162        let conn = self.conn.lock().await;
163        let mut stmt = conn.prepare(
164            "SELECT device_id, device_type, name, token_hash, last_seen, battery_pct, prefs, ambient_meeting_id, created_at
165             FROM devices ORDER BY created_at DESC",
166        )?;
167        let rows = stmt.query_map([], Self::row_to_record)?;
168        let mut out = Vec::new();
169        for row in rows {
170            out.push(row?);
171        }
172        Ok(out)
173    }
174
175    /// Verify a raw Bearer token against a device's stored hash. Returns `true`
176    /// only when the device exists and the hash matches.
177    pub async fn verify_token(&self, device_id: &str, token: &str) -> Result<bool> {
178        let Some(record) = self.get(device_id).await? else {
179            return Ok(false);
180        };
181        Ok(record.token_hash == hash_token(token))
182    }
183
184    /// Update mutable fields (name/prefs) for `PATCH`. A `None` leaves the field
185    /// unchanged. Returns `true` when a row was touched.
186    pub async fn update(
187        &self,
188        device_id: &str,
189        name: Option<String>,
190        prefs: Option<serde_json::Value>,
191    ) -> Result<bool> {
192        let conn = self.conn.lock().await;
193        let mut changed = 0usize;
194        if let Some(name) = name {
195            changed += conn.execute(
196                "UPDATE devices SET name = ?2 WHERE device_id = ?1",
197                params![device_id, name],
198            )?;
199        }
200        if let Some(prefs) = prefs {
201            let json = serde_json::to_string(&prefs).unwrap_or_else(|_| "{}".to_string());
202            changed += conn.execute(
203                "UPDATE devices SET prefs = ?2 WHERE device_id = ?1",
204                params![device_id, json],
205            )?;
206        }
207        Ok(changed > 0)
208    }
209
210    /// Mark a device seen now and record latest battery (from telemetry). A
211    /// `None` battery leaves the stored value unchanged.
212    pub async fn touch(&self, device_id: &str, battery_pct: Option<i32>) -> Result<()> {
213        let conn = self.conn.lock().await;
214        match battery_pct {
215            Some(pct) => conn.execute(
216                "UPDATE devices SET last_seen = ?2, battery_pct = ?3 WHERE device_id = ?1",
217                params![device_id, now_ms(), pct],
218            )?,
219            None => conn.execute(
220                "UPDATE devices SET last_seen = ?2 WHERE device_id = ?1",
221                params![device_id, now_ms()],
222            )?,
223        };
224        Ok(())
225    }
226
227    /// Persist the long-running ambient meeting id for a device so a reconnect
228    /// resumes the same meeting (PROTOCOL.md §4.2).
229    pub async fn set_ambient_meeting(&self, device_id: &str, meeting_id: &str) -> Result<()> {
230        let conn = self.conn.lock().await;
231        conn.execute(
232            "UPDATE devices SET ambient_meeting_id = ?2 WHERE device_id = ?1",
233            params![device_id, meeting_id],
234        )?;
235        Ok(())
236    }
237
238    /// Revoke a device (deletes it / its token) for `DELETE`. Returns `true` when
239    /// a row was removed.
240    pub async fn revoke(&self, device_id: &str) -> Result<bool> {
241        let conn = self.conn.lock().await;
242        let n = conn.execute(
243            "DELETE FROM devices WHERE device_id = ?1",
244            params![device_id],
245        )?;
246        Ok(n > 0)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn temp_store() -> DeviceStore {
255        let dir = std::env::temp_dir().join(format!("ryu-hw-test-{}", uuid::Uuid::new_v4()));
256        DeviceStore::open(dir.join("hardware.db")).expect("open")
257    }
258
259    fn sample(id: &str, token: &str) -> DeviceRecord {
260        DeviceRecord {
261            device_id: id.to_string(),
262            device_type: DeviceType::Watch,
263            name: "Test Watch".to_string(),
264            token_hash: hash_token(token),
265            last_seen: None,
266            battery_pct: None,
267            prefs: serde_json::json!({}),
268            ambient_meeting_id: None,
269            created_at: now_ms(),
270        }
271    }
272
273    #[tokio::test]
274    async fn insert_get_and_verify_token() {
275        let store = temp_store();
276        store.insert(sample("rhw_1", "secret-token")).await.unwrap();
277
278        let got = store.get("rhw_1").await.unwrap().expect("present");
279        assert_eq!(got.device_id, "rhw_1");
280        assert_eq!(got.device_type, DeviceType::Watch);
281
282        assert!(store.verify_token("rhw_1", "secret-token").await.unwrap());
283        assert!(!store.verify_token("rhw_1", "wrong").await.unwrap());
284        assert!(!store.verify_token("nope", "secret-token").await.unwrap());
285    }
286
287    #[tokio::test]
288    async fn touch_updates_last_seen_and_battery() {
289        let store = temp_store();
290        store.insert(sample("rhw_2", "t")).await.unwrap();
291        store.touch("rhw_2", Some(81)).await.unwrap();
292        let got = store.get("rhw_2").await.unwrap().unwrap();
293        assert!(got.last_seen.is_some());
294        assert_eq!(got.battery_pct, Some(81));
295        // A None battery keeps the previous value.
296        store.touch("rhw_2", None).await.unwrap();
297        let got = store.get("rhw_2").await.unwrap().unwrap();
298        assert_eq!(got.battery_pct, Some(81));
299    }
300
301    #[tokio::test]
302    async fn update_revoke_and_ambient() {
303        let store = temp_store();
304        store.insert(sample("rhw_3", "t")).await.unwrap();
305        assert!(store
306            .update("rhw_3", Some("Kitchen".into()), None)
307            .await
308            .unwrap());
309        store.set_ambient_meeting("rhw_3", "mtg_x").await.unwrap();
310        let got = store.get("rhw_3").await.unwrap().unwrap();
311        assert_eq!(got.name, "Kitchen");
312        assert_eq!(got.ambient_meeting_id.as_deref(), Some("mtg_x"));
313        assert!(store.revoke("rhw_3").await.unwrap());
314        assert!(store.get("rhw_3").await.unwrap().is_none());
315    }
316}