Skip to main content

agentmux_srv/backend/storage/
viewer_devices.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Paired devices: the viewer grants a device receives at pairing, and each
5//! agent's "hide from paired devices" flag. See `db_viewer_devices` and
6//! `db_agents.hide_from_devices` in migrations.rs (OBJECT_SCHEMA_VERSION v43)
7//! and `backend::viewer`.
8//!
9//! A device's token is never stored, only its SHA-256 (lowercase hex), so a
10//! copy of this database opens no feed. Revoking deletes the row.
11
12use rusqlite::params;
13
14use super::error::StoreError;
15use super::store::Store;
16
17/// One paired device, as Settings lists it. Never carries the token hash.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ViewerDevice {
20    pub device_id: String,
21    pub device_name: String,
22    /// Standard base64 of the device's 32-byte public key, '' when it sent none.
23    pub device_key: String,
24    pub created_ms: i64,
25    /// 0 until the device's first request after pairing.
26    pub last_seen_ms: i64,
27}
28
29impl Store {
30    /// Record a new paired device.
31    pub fn viewer_device_insert(
32        &self,
33        device_id: &str,
34        token_hash: &str,
35        device_name: &str,
36        device_key: &str,
37        created_ms: i64,
38    ) -> Result<(), StoreError> {
39        let conn = self.conn.lock().unwrap();
40        conn.execute(
41            "INSERT INTO db_viewer_devices \
42             (device_id, token_hash, device_name, device_key, created_ms, last_seen_ms) \
43             VALUES (?1, ?2, ?3, ?4, ?5, 0)",
44            params![device_id, token_hash, device_name, device_key, created_ms],
45        )?;
46        Ok(())
47    }
48
49    /// Every paired device, oldest first.
50    pub fn viewer_device_list(&self) -> Result<Vec<ViewerDevice>, StoreError> {
51        let conn = self.conn.lock().unwrap();
52        let mut stmt = conn.prepare(
53            "SELECT device_id, device_name, device_key, created_ms, last_seen_ms \
54             FROM db_viewer_devices ORDER BY created_ms, device_id",
55        )?;
56        let rows = stmt.query_map([], |row| {
57            Ok(ViewerDevice {
58                device_id: row.get(0)?,
59                device_name: row.get(1)?,
60                device_key: row.get(2)?,
61                created_ms: row.get(3)?,
62                last_seen_ms: row.get(4)?,
63            })
64        })?;
65        Ok(rows.collect::<Result<Vec<_>, _>>()?)
66    }
67
68    /// `(device_id, token_hash)` of every paired device, for the bearer check,
69    /// which compares each hash in constant time rather than looking one up.
70    pub fn viewer_device_token_hashes(&self) -> Result<Vec<(String, String)>, StoreError> {
71        let conn = self.conn.lock().unwrap();
72        let mut stmt = conn.prepare("SELECT device_id, token_hash FROM db_viewer_devices")?;
73        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
74        Ok(rows.collect::<Result<Vec<_>, _>>()?)
75    }
76
77    /// Stamp a device's last request. False when it was revoked meanwhile.
78    pub fn viewer_device_touch(&self, device_id: &str, last_seen_ms: i64) -> Result<bool, StoreError> {
79        let conn = self.conn.lock().unwrap();
80        let rows = conn.execute(
81            "UPDATE db_viewer_devices SET last_seen_ms = ?2 WHERE device_id = ?1",
82            params![device_id, last_seen_ms],
83        )?;
84        Ok(rows > 0)
85    }
86
87    /// Revoke a device. False when there was no such device.
88    pub fn viewer_device_delete(&self, device_id: &str) -> Result<bool, StoreError> {
89        let conn = self.conn.lock().unwrap();
90        let rows = conn.execute("DELETE FROM db_viewer_devices WHERE device_id = ?1", params![device_id])?;
91        Ok(rows > 0)
92    }
93
94    /// Whether agent `id` (a definition id) is hidden from paired devices.
95    /// False for an unknown agent.
96    pub fn agent_hide_from_devices_get(&self, id: &str) -> Result<bool, StoreError> {
97        let conn = self.conn.lock().unwrap();
98        match conn.query_row(
99            "SELECT hide_from_devices FROM db_agents WHERE id = ?1",
100            params![id],
101            |row| row.get::<_, i64>(0),
102        ) {
103            Ok(v) => Ok(v != 0),
104            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
105            Err(e) => Err(e.into()),
106        }
107    }
108
109    /// Hide agent `id` from paired devices, or show it again. False for an
110    /// unknown agent. Leaves `updated_at` alone, like `agent_last_runtime_set`:
111    /// who may watch the agent is not an edit of how it runs.
112    pub fn agent_hide_from_devices_set(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
113        let conn = self.conn.lock().unwrap();
114        let rows = conn.execute(
115            "UPDATE db_agents SET hide_from_devices = ?2 WHERE id = ?1",
116            params![id, hidden as i64],
117        )?;
118        Ok(rows > 0)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn devices_are_listed_touched_and_revoked() {
128        let store = Store::open_in_memory().unwrap();
129        assert!(store.viewer_device_list().unwrap().is_empty());
130        store.viewer_device_insert("d1", "aa", "Pixel", "", 100).unwrap();
131        store.viewer_device_insert("d2", "bb", "Tablet", "a2V5", 200).unwrap();
132        let list = store.viewer_device_list().unwrap();
133        assert_eq!(list.len(), 2);
134        assert_eq!(list[0].device_name, "Pixel");
135        assert_eq!(list[1].device_key, "a2V5");
136        assert_eq!(list[0].last_seen_ms, 0);
137
138        assert!(store.viewer_device_touch("d1", 5_000).unwrap());
139        assert_eq!(store.viewer_device_list().unwrap()[0].last_seen_ms, 5_000);
140        assert_eq!(
141            store.viewer_device_token_hashes().unwrap().len(),
142            2,
143            "the bearer check sees every device"
144        );
145
146        assert!(store.viewer_device_delete("d1").unwrap());
147        assert!(!store.viewer_device_delete("d1").unwrap(), "already gone");
148        assert!(!store.viewer_device_touch("d1", 6_000).unwrap());
149        assert_eq!(store.viewer_device_list().unwrap().len(), 1);
150    }
151
152    #[test]
153    fn a_token_hash_belongs_to_one_device() {
154        let store = Store::open_in_memory().unwrap();
155        store.viewer_device_insert("d1", "same", "A", "", 1).unwrap();
156        assert!(store.viewer_device_insert("d2", "same", "B", "", 2).is_err());
157    }
158
159    #[test]
160    fn the_hide_flag_defaults_off_and_is_kept_per_agent() {
161        let store = Store::open_in_memory().unwrap();
162        {
163            let conn = store.conn.lock().unwrap();
164            conn.execute_batch(
165                "INSERT INTO db_agents (id, name, provider, created_at) VALUES ('a1', 'One', 'claude', 0);
166                 INSERT INTO db_agents (id, name, provider, created_at) VALUES ('a2', 'Two', 'claude', 0);",
167            )
168            .unwrap();
169        }
170        assert!(!store.agent_hide_from_devices_get("a1").unwrap());
171        assert!(store.agent_hide_from_devices_set("a1", true).unwrap());
172        assert!(store.agent_hide_from_devices_get("a1").unwrap());
173        assert!(!store.agent_hide_from_devices_get("a2").unwrap(), "another agent is unaffected");
174        assert!(store.agent_hide_from_devices_set("a1", false).unwrap());
175        assert!(!store.agent_hide_from_devices_get("a1").unwrap());
176        assert!(!store.agent_hide_from_devices_set("nope", true).unwrap());
177        assert!(!store.agent_hide_from_devices_get("nope").unwrap());
178    }
179}