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}