Skip to main content

app_facade_api/
hub.rs

1use serde::{Deserialize, Serialize};
2
3pub const GET_TARGET: &str = "/app/hub/get";
4pub const REMOVE_TARGET: &str = "/app/hub/remove";
5pub const CACHE_CLEAR_TARGET: &str = "/app/hub/cache/clear";
6
7pub const LOCAL_GET_TARGET: &str = "/local/hub/get";
8pub const LOCAL_CANDIDATES_TARGET: &str = "/local/hub/candidates";
9pub const LOCAL_SETUP_START_TARGET: &str = "/local/hub/setup/start";
10pub const LOCAL_SETUP_CONFIRM_TARGET: &str = "/local/hub/setup/confirm";
11pub const LOCAL_SETUP_STATUS_TARGET: &str = "/local/hub/setup/status";
12pub const LOCAL_SETUP_CANCEL_TARGET: &str = "/local/hub/setup/cancel";
13
14pub const APP_TARGETS: &[&str] = &[GET_TARGET, REMOVE_TARGET, CACHE_CLEAR_TARGET];
15pub const LOCAL_TARGETS: &[&str] = &[
16    LOCAL_GET_TARGET,
17    LOCAL_CANDIDATES_TARGET,
18    LOCAL_SETUP_START_TARGET,
19    LOCAL_SETUP_CONFIRM_TARGET,
20    LOCAL_SETUP_STATUS_TARGET,
21    LOCAL_SETUP_CANCEL_TARGET,
22];
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct HubGetRequest {
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub known_hub_id: Option<String>,
29}
30
31#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct HubState {
34    pub scope_id: String,
35    pub configured: bool,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub hub: Option<HubView>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub operation: Option<HubOperation>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub revocation: Option<HubRevocation>,
42}
43
44impl HubState {
45    pub fn validate(&self, known_hub_id: Option<&str>) -> Result<(), &'static str> {
46        if self.configured != self.hub.is_some() {
47            return Err("configured must exactly match whether a published Hub is present");
48        }
49        if let Some(revocation) = &self.revocation {
50            if known_hub_id != Some(revocation.hub_id.as_str()) {
51                return Err("revocation must exactly match knownHubId");
52            }
53        }
54        Ok(())
55    }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct HubView {
61    pub id: String,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    #[serde(rename = "type")]
65    pub hub_type: String,
66    pub host_id: String,
67    pub is_test: bool,
68    pub status: String,
69    pub cloud: HubCloudView,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct HubCloudView {
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub connection_status: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub last_connected_at: Option<i64>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub firmware_version: Option<String>,
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
85pub enum HubOperationKind {
86    Setup,
87    Remove,
88}
89
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
92pub enum HubOperationState {
93    Running,
94    Failed,
95}
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
99pub enum HubOperationPhase {
100    WaitingForHub,
101    PreparingData,
102    SyncingData,
103    BackingUpData,
104    FencingCloud,
105    ClearingLocalData,
106    Finalizing,
107    CleaningUp,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112pub struct HubOperation {
113    pub kind: HubOperationKind,
114    pub state: HubOperationState,
115    pub phase: HubOperationPhase,
116    pub started_at: i64,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub expires_at: Option<i64>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub host_id: Option<String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub hub_id: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub failure: Option<HubOperationFailure>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct HubOperationFailure {
130    pub reason: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub at: Option<i64>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct HubRevocation {
138    pub hub_id: String,
139    pub revocation_id: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "camelCase", deny_unknown_fields)]
144pub struct HubRemoveRequest {
145    pub hub_id: String,
146    #[serde(default)]
147    pub force: bool,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "camelCase", deny_unknown_fields)]
152pub struct HubRemoveResponse {
153    pub accepted: bool,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub operation: Option<HubOperation>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct HubCacheClearRequest {
161    pub hub_id: String,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct MutationResponse {
167    pub ok: bool,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "camelCase", deny_unknown_fields)]
172pub struct LocalHubState {
173    pub scope_id: String,
174    pub configured: bool,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub hub: Option<LocalHubView>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub operation: Option<HubOperation>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub revocation: Option<HubRevocation>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub pairing: Option<HubPairingOperation>,
183}
184
185impl LocalHubState {
186    pub fn validate(&self, known_hub_id: Option<&str>) -> Result<(), &'static str> {
187        if self.configured != self.hub.is_some() {
188            return Err("configured must exactly match whether a published Hub is present");
189        }
190        if let Some(revocation) = &self.revocation {
191            if known_hub_id != Some(revocation.hub_id.as_str()) {
192                return Err("revocation must exactly match knownHubId");
193            }
194        }
195        Ok(())
196    }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201pub struct LocalHubView {
202    #[serde(flatten)]
203    pub cloud: HubView,
204    pub local: LocalHubRuntime,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct LocalHubRuntime {
210    pub discovered: bool,
211    pub connected: bool,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub connection_status: Option<String>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub host_name: Option<String>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub version: Option<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(rename_all = "camelCase", deny_unknown_fields)]
222pub struct HubPairingOperation {
223    pub session_id: String,
224    pub state: String,
225    pub phase: String,
226    pub started_at: i64,
227    pub expires_at: i64,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub host_id: Option<String>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub hub_id: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub failure: Option<HubOperationFailure>,
234}
235
236#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238pub struct HubCandidatesResponse {
239    pub scope_id: String,
240    #[serde(default)]
241    pub candidates: Vec<HubCandidate>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct HubCandidate {
247    pub host_id: String,
248    pub host_name: String,
249    #[serde(rename = "type")]
250    pub hub_type: String,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub version: Option<String>,
253    pub available: bool,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[serde(rename_all = "camelCase", deny_unknown_fields)]
258pub struct HubEndpoint {
259    pub address: String,
260    pub port: u16,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264#[serde(rename_all = "camelCase", deny_unknown_fields)]
265pub struct HubSetupStartRequest {
266    pub host_id: String,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub endpoint: Option<HubEndpoint>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase", deny_unknown_fields)]
273pub struct HubSetupStartResponse {
274    pub scope_id: String,
275    pub session_id: String,
276    pub candidate: HubCandidate,
277    pub pairing_code: String,
278    pub expires_at: i64,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283pub struct HubSetupSessionRequest {
284    pub session_id: String,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
288#[serde(rename_all = "camelCase", deny_unknown_fields)]
289pub struct HubSetupConfirmResponse {
290    pub scope_id: String,
291    pub session_id: String,
292    pub accepted: bool,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "camelCase", deny_unknown_fields)]
297pub struct HubSetupStatusResponse {
298    pub scope_id: String,
299    pub session_id: String,
300    pub complete: bool,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub pairing: Option<HubPairingOperation>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub operation: Option<HubOperation>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(rename_all = "camelCase", deny_unknown_fields)]
309pub struct HubSetupCancelResponse {
310    pub scope_id: String,
311    pub session_id: String,
312    pub cancelled: bool,
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn cloud_state_enforces_published_and_exact_revocation_invariants() {
321        let state = HubState {
322            scope_id: "scope-1".into(),
323            configured: false,
324            revocation: Some(HubRevocation {
325                hub_id: "hub-old".into(),
326                revocation_id: "revoke-1".into(),
327            }),
328            ..HubState::default()
329        };
330        assert!(state.validate(Some("hub-old")).is_ok());
331        assert!(state.validate(Some("hub-other")).is_err());
332
333        let mut invalid = state;
334        invalid.configured = true;
335        assert!(invalid.validate(Some("hub-old")).is_err());
336    }
337
338    #[test]
339    fn local_targets_are_disjoint_and_never_public_app_targets() {
340        assert!(APP_TARGETS.iter().all(|target| target.starts_with("/app/")));
341        assert!(LOCAL_TARGETS
342            .iter()
343            .all(|target| target.starts_with("/local/")));
344        assert!(APP_TARGETS
345            .iter()
346            .all(|target| !LOCAL_TARGETS.contains(target)));
347    }
348}