Skip to main content

vta_sdk/protocols/
device_management.rs

1//! Canonical `device/*` request bodies.
2//!
3//! The #888 fold, applied to the family it did not reach. These methods used to
4//! build their payloads as an inline `json!` plus a conditional insert per
5//! optional member:
6//!
7//! ```ignore
8//! let mut payload = json!({ "consumerKind": …, "displayName": … });
9//! if let Some(p) = platform { payload["platform"] = json!(p); }
10//! ```
11//!
12//! That shape is not wrong — the conditional insert is what kept `null` off the
13//! wire — but it is unguarded and untestable. Unguarded because the invariant
14//! lives in the shape of an `if let` rather than in an attribute, so nothing
15//! checks it: the `vta-sdk` null census walks these structs and would have
16//! caught `keys/create`, and it cannot see an inline map. Untestable because a
17//! conformance witness has no type to point at, so it hand-writes the JSON and
18//! stops tracking the producer the moment the producer changes.
19//!
20//! With a body struct both fall out for free: `skip_serializing_if` is what
21//! keeps the member absent, the census enforces it, and the witness is built
22//! rather than transcribed.
23//!
24//! Members mirror `device/*/0.1`. Only what the client can actually send is
25//! modelled — `attestation` and `keyCustody` are in the schema but have no
26//! producer here yet, and a field nothing sets is a claim the type should not
27//! make.
28
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32/// `device/register/0.1` — claim a `DeviceBinding` on the caller's ACL entry.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct DeviceRegisterBody {
36    /// The tagged `ConsumerKind` union (`{kind: "service", serviceKind: …}`).
37    ///
38    /// Stays a `Value` because the caller supplies it as one and the union has
39    /// no Rust model here yet; modelling it is an API change, not a fold.
40    pub consumer_kind: Value,
41    pub display_name: String,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub platform: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub hpke_public_key: Option<String>,
46}
47
48/// `device/heartbeat/0.1` — refresh `lastSeenAt`, and `platform` if supplied.
49///
50/// Every member is optional: an empty body is the common case (a bare "still
51/// here"), and it must serialize to `{}`, not to a map of nulls.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct DeviceHeartbeatBody {
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub platform: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub vault_seq: Option<u64>,
59}
60
61/// `device/disable/0.1` — disable a device by id; the record is kept.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct DeviceDisableBody {
65    pub device_id: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub reason: Option<String>,
68}
69
70/// `device/wipe/0.1` — remote-wipe a compromised or lost device.
71///
72/// `scope` and `reason` are both required by the spec: a wipe with no recorded
73/// reason is an audit gap, and the schema refuses one.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct DeviceWipeBody {
77    pub device_id: String,
78    /// `cache` | `cache-and-keys` | `full`.
79    pub scope: String,
80    pub reason: String,
81}
82
83/// The device's opaque push handle.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct WakeHandle {
87    pub gateway: String,
88    pub handle: String,
89}
90
91/// `device/set-wake/0.1` — convey the device's `WakeHandle`.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct DeviceSetWakeBody {
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub wake_handle: Option<WakeHandle>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub suggested_triggers: Option<Vec<String>>,
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    /// The property the fold exists to make enforceable: an unset optional is
106    /// absent, so a bare heartbeat is `{}` rather than a map of nulls.
107    ///
108    /// The old inline builder got this right by construction. Nothing checked
109    /// it, and `keys/create` is what that costs when someone later reaches for
110    /// a struct instead (#919).
111    #[test]
112    fn a_bare_heartbeat_is_an_empty_object() {
113        assert_eq!(
114            serde_json::to_value(DeviceHeartbeatBody::default()).expect("serialises"),
115            serde_json::json!({})
116        );
117    }
118
119    #[test]
120    fn an_unset_register_member_is_absent() {
121        let minimal = DeviceRegisterBody {
122            consumer_kind: serde_json::json!({"kind": "companion", "formFactor": "desktop"}),
123            display_name: "laptop".into(),
124            platform: None,
125            hpke_public_key: None,
126        };
127        assert_eq!(
128            serde_json::to_value(&minimal).expect("serialises"),
129            serde_json::json!({
130                "consumerKind": {"kind": "companion", "formFactor": "desktop"},
131                "displayName": "laptop",
132            })
133        );
134    }
135
136    /// Set members still reach the wire under their canonical camelCase names —
137    /// the skip must not be reachable for `Some`.
138    #[test]
139    fn set_members_serialise_camel_case() {
140        let full = DeviceRegisterBody {
141            consumer_kind: serde_json::json!({"kind": "service", "serviceKind": "ai-agent"}),
142            display_name: "agent".into(),
143            platform: Some("macos".into()),
144            hpke_public_key: Some("zHpke".into()),
145        };
146        let v = serde_json::to_value(&full).expect("serialises");
147        assert_eq!(v.get("platform").and_then(Value::as_str), Some("macos"));
148        assert_eq!(
149            v.get("hpkePublicKey").and_then(Value::as_str),
150            Some("zHpke")
151        );
152    }
153
154    #[test]
155    fn a_wake_handle_nests_under_its_camel_case_member() {
156        let body = DeviceSetWakeBody {
157            wake_handle: Some(WakeHandle {
158                gateway: "apns".into(),
159                handle: "opaque".into(),
160            }),
161            suggested_triggers: Some(vec!["message".into()]),
162        };
163        assert_eq!(
164            serde_json::to_value(&body).expect("serialises"),
165            serde_json::json!({
166                "wakeHandle": {"gateway": "apns", "handle": "opaque"},
167                "suggestedTriggers": ["message"],
168            })
169        );
170    }
171}