Skip to main content

sapphire_framework_bridge_api/
lib.rs

1//! What an app server says to the bridge, and how it says it.
2//!
3//! Kept separate from `sapphire-framework-bridge` so that an app server can talk to the
4//! bridge without linking iroh: Cargo unifies features across a workspace build, so a
5//! feature flag on one crate would not have been enough.
6//!
7//! See `docs/superpowers/specs/2026-09-16-process-architecture-design.md` §4.3 and §5.
8
9#![warn(missing_docs)]
10
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15pub use grain_id::GrainId;
16
17/// How an app server process came to exist, as it tells the bridge in [`REGISTER`].
18///
19/// The same shape as `sapphire_ipc::ManagedBy`, defined here so this crate does not
20/// depend on the transport.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ManagedBy {
24    /// Started by the OS service manager. The bridge never starts it.
25    Service,
26    /// Started on demand.
27    Spawned,
28}
29
30mod embed;
31pub use embed::{
32    ApiKey, DEFAULT_DIMENSION, DEFAULT_MAX_TOKENS, DeviceSettings, EmbedDeviceSetParams,
33    EmbedKeySetParams, EmbedModelSetParams, EmbedNote, EmbedRequest, EmbedSettingsResult,
34    LOCAL_MODEL, LocalModel, MAX_TOKENS, ModelSettings, ModelSource, QType, RemoteModel, Slot,
35    SlotModel, describe,
36};
37
38mod external;
39pub use external::{
40    ExternalDeviceAddParams, ExternalDeviceAuthenticateParams, ExternalDeviceAuthenticateResult,
41    ExternalDeviceInfo, ExternalDeviceListResult, ExternalDeviceOutcome, ExternalDeviceRequest,
42    ExternalDeviceSelectParams, ExternalDeviceSetAppsParams, ExternalDeviceTokenResult,
43    describe_external_device, describe_external_outcome,
44};
45
46#[cfg(feature = "cli")]
47mod cli;
48#[cfg(feature = "cli")]
49pub use cli::{
50    EmbeddingCommand, ExternalDeviceCommand, KeyCommand, LocalCommand, RemoteCommand, Switch,
51    read_key,
52};
53
54/// The version of the bridge's control-plane API: the methods below and their types.
55///
56/// It is this crate's major version, parsed at compile time, so the two cannot drift: a
57/// breaking change to a method, a parameter or a result here is a major release of this
58/// crate, and that release is the new API version. The handshake compares this number.
59pub const API_VERSION: u32 = parse_major(env!("CARGO_PKG_VERSION_MAJOR"));
60
61/// `CARGO_PKG_VERSION_MAJOR` as a number. Cargo guarantees it is decimal digits.
62const fn parse_major(s: &str) -> u32 {
63    let bytes = s.as_bytes();
64    let mut i = 0;
65    let mut n = 0u32;
66    while i < bytes.len() {
67        n = n * 10 + (bytes[i] - b'0') as u32;
68        i += 1;
69    }
70    n
71}
72
73/// The endpoint name the bridge's control plane listens under.
74pub const BRIDGE_NAME: &str = "bridge";
75/// The endpoint name the bridge's data plane listens under.
76pub const BRIDGE_DATA_NAME: &str = "bridge-data";
77/// The application-layer protocol name used on every peer connection.
78pub const ALPN: &[u8] = b"sapphire/ws/1";
79
80/// Announce which workspaces this app server owns.
81pub const REGISTER: &str = "bridge.register";
82/// Stop owning one workspace.
83pub const UNREGISTER: &str = "bridge.unregister";
84/// List the workgroup's devices and whether they are connected.
85pub const PEERS: &str = "bridge.peers";
86/// Describe the bridge.
87pub const STATUS: &str = "bridge.status";
88/// Notification: a peer wants a workspace this app server owns.
89pub const INCOMING: &str = "bridge.incoming";
90/// Create an invite, and get the ticket to hand to the joining device.
91pub const INVITE: &str = "bridge.invite";
92/// Join the workgroup a ticket names.
93pub const JOIN: &str = "bridge.join";
94/// List the workspaces the workgroup knows about.
95pub const WORKSPACES: &str = "bridge.workspaces";
96/// Found a workgroup on this host, as its first device.
97pub const WORKGROUP_CREATE: &str = "bridge.workgroup_create";
98/// Retire a device of this host's workgroup.
99pub const DEVICE_RETIRE: &str = "bridge.device_retire";
100/// Ask the bridge which embedding model it serves, if any.
101pub const EMBED_INFO: &str = "embed.info";
102/// Embed texts with the bridge's embedding model.
103pub const EMBED: &str = "embed.embed";
104/// Embed images with the bridge's embedding model, into the texts' space. Served only when
105/// [`EmbedModelInfo::images`] is set; absent from bridges before 2.4.
106pub const EMBED_IMAGES: &str = "embed.embed_images";
107/// List the workgroup's external devices.
108pub const EXTERNAL_DEVICE_LIST: &str = "external_device.list";
109/// Add an external device; the answer carries its token, once.
110pub const EXTERNAL_DEVICE_ADD: &str = "external_device.add";
111/// Retire an external device: its token stops working, the record stays.
112pub const EXTERNAL_DEVICE_RETIRE: &str = "external_device.retire";
113/// Bring a retired external device back, with its token.
114pub const EXTERNAL_DEVICE_RESTORE: &str = "external_device.restore";
115/// Replace an external device's token, keeping its id.
116pub const EXTERNAL_DEVICE_ROTATE: &str = "external_device.rotate";
117/// Set the applications an external device may use.
118pub const EXTERNAL_DEVICE_SET_APPS: &str = "external_device.set_apps";
119/// Check a presented token for an application: who it is, or an error.
120pub const EXTERNAL_DEVICE_AUTHENTICATE: &str = "external_device.authenticate";
121/// Read the embedding settings and what they resolve to.
122pub const EMBED_SETTINGS: &str = "embed.settings";
123/// Set or clear one model slot.
124pub const EMBED_MODEL_SET: &str = "embed.model_set";
125/// Switch one slot on or off on this device.
126pub const EMBED_DEVICE_SET: &str = "embed.device_set";
127/// Store the remote slot's API key on this device.
128pub const EMBED_KEY_SET: &str = "embed.key_set";
129/// Remove the remote slot's API key from this device.
130pub const EMBED_KEY_CLEAR: &str = "embed.key_clear";
131
132/// Set a device's election priority.
133pub const DEVICE_PRIORITY_SET: &str = "bridge.device_priority_set";
134
135/// A device's priority when its record names none. Mirrors the registry's own constant;
136/// this crate does not depend on the registry.
137pub const DEFAULT_PRIORITY: u8 = 1;
138
139fn default_priority() -> u8 {
140    DEFAULT_PRIORITY
141}
142
143/// Parameters of [`DEVICE_PRIORITY_SET`].
144#[derive(Clone, Debug, Deserialize, Serialize)]
145pub struct DevicePrioritySetParams {
146    /// The device's name or id.
147    pub selector: String,
148    /// `0..=255`. `0` takes the device out of the election.
149    pub priority: u8,
150}
151
152/// Result of [`DEVICE_PRIORITY_SET`].
153#[derive(Clone, Debug, Deserialize, Serialize)]
154pub struct DevicePrioritySetResult {
155    /// The device's id.
156    pub device_id: GrainId,
157    /// Its name.
158    pub name: String,
159    /// Its priority now.
160    pub priority: u8,
161}
162
163/// Parameters of [`INVITE`].
164#[derive(Clone, Debug, Deserialize, Serialize)]
165pub struct InviteParams {
166    /// What the joining device will be called in the ledger.
167    pub name: String,
168    /// How long the invite stays good, in seconds. `None` uses the bridge's default.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub ttl: Option<u64>,
171    /// The workgroup to invite into, by name or id. `None` uses this host's only one.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub workgroup: Option<String>,
174}
175
176/// Result of [`INVITE`].
177#[derive(Clone, Debug, Deserialize, Serialize)]
178pub struct InviteResult {
179    /// The ticket, in the text form a user copies to the joining device.
180    pub ticket: String,
181}
182
183/// Parameters of [`WORKGROUP_CREATE`].
184#[derive(Clone, Debug, Deserialize, Serialize)]
185pub struct WorkgroupCreateParams {
186    /// The workgroup's name.
187    pub name: String,
188    /// This host's device name inside it.
189    pub device_name: String,
190}
191
192/// Result of [`WORKGROUP_CREATE`].
193#[derive(Clone, Debug, Deserialize, Serialize)]
194pub struct WorkgroupCreateResult {
195    /// The new workgroup's id.
196    pub workgroup_id: GrainId,
197    /// Its name.
198    pub name: String,
199    /// This host's device id in it.
200    pub device_id: GrainId,
201}
202
203/// Parameters of [`DEVICE_RETIRE`].
204#[derive(Clone, Debug, Deserialize, Serialize)]
205pub struct DeviceRetireParams {
206    /// The device's name or id.
207    pub selector: String,
208}
209
210/// Result of [`DEVICE_RETIRE`].
211#[derive(Clone, Debug, Deserialize, Serialize)]
212pub struct DeviceRetireResult {
213    /// The retired device's id.
214    pub device_id: GrainId,
215    /// Its name.
216    pub name: String,
217}
218
219/// Parameters of [`JOIN`].
220#[derive(Clone, Debug, Deserialize, Serialize)]
221pub struct JoinParams {
222    /// The ticket the inviter produced.
223    pub ticket: String,
224    /// The name this device will carry in the ledger. `None` uses the host name.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub device_name: Option<String>,
227}
228
229/// Result of [`JOIN`].
230#[derive(Clone, Debug, Deserialize, Serialize)]
231pub struct JoinResult {
232    /// The workgroup that was joined.
233    pub workgroup_id: GrainId,
234    /// Its name, as the founding device chose it.
235    pub workgroup_name: String,
236    /// This device's own record inside it.
237    pub device_id: GrainId,
238}
239
240/// One workspace the workgroup knows about.
241#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
242pub struct WorkgroupWorkspaceInfo {
243    /// Its identity across devices.
244    pub workspace_id: GrainId,
245    /// The application that owns it.
246    pub app_name: String,
247    /// The name the workgroup lists it under.
248    pub name: String,
249}
250
251/// Result of [`WORKSPACES`].
252#[derive(Clone, Debug, Deserialize, Serialize)]
253pub struct WorkspacesResult {
254    /// Every workspace the workgroup knows about.
255    pub workspaces: Vec<WorkgroupWorkspaceInfo>,
256}
257
258/// The success payload of a call that returns nothing.
259#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
260pub struct Ack {}
261
262/// One workspace an app server owns.
263#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
264pub struct WorkspaceRegistration {
265    /// The workspace's sync identity, shared across devices.
266    pub workspace_id: GrainId,
267    /// Where it lives on this host.
268    pub root: PathBuf,
269}
270
271/// Parameters of [`REGISTER`].
272#[derive(Clone, Debug, Deserialize, Serialize)]
273pub struct RegisterParams {
274    /// Which application this server belongs to.
275    pub app_name: String,
276    /// The executable to run when a peer wants a workspace and this server is not running.
277    pub exe_path: PathBuf,
278    /// How this server was started. A `Service` server is never started by the bridge.
279    pub managed_by: ManagedBy,
280    /// The workspaces it owns. Registering again replaces the previous list for this app.
281    pub workspaces: Vec<WorkspaceRegistration>,
282}
283
284/// Result of [`REGISTER`].
285#[derive(Clone, Debug, Deserialize, Serialize)]
286pub struct RegisterResult {
287    /// This host's device id inside the workgroup, used as `Entry.author`.
288    pub device_id: GrainId,
289    /// This host's iroh node id.
290    pub node_id: String,
291    /// The workgroup these workspaces belong to.
292    pub workgroup_id: GrainId,
293}
294
295/// Parameters of [`UNREGISTER`].
296#[derive(Clone, Debug, Deserialize, Serialize)]
297pub struct UnregisterParams {
298    /// The workspace to stop owning.
299    pub workspace_id: GrainId,
300}
301
302/// Who the bridge elected for one workspace this host's app servers own.
303#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
304pub struct WorkspaceRoles {
305    /// The workspace.
306    pub workspace_id: GrainId,
307    /// The primary device, if any candidate exists.
308    #[serde(default)]
309    pub primary: Option<GrainId>,
310    /// The secondary device, if a second candidate exists.
311    #[serde(default)]
312    pub secondary: Option<GrainId>,
313}
314
315/// One device of the workgroup.
316#[derive(Clone, Debug, Deserialize, Serialize)]
317pub struct PeerInfo {
318    /// Its device id.
319    pub device_id: GrainId,
320    /// Its name.
321    pub name: String,
322    /// Its iroh node id.
323    pub node_id: String,
324    /// Whether the bridge currently holds a connection to it.
325    pub connected: bool,
326    /// Its election priority, as its ledger record says.
327    #[serde(default = "default_priority")]
328    pub priority: u8,
329    /// Its availability tier, as its own Hello reports it (#190). `None` until measured.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub availability: Option<u8>,
332}
333
334/// Result of [`PEERS`].
335#[derive(Clone, Debug, Deserialize, Serialize)]
336pub struct PeersResult {
337    /// Every non-retired device of the workgroup, this host included.
338    pub peers: Vec<PeerInfo>,
339    /// The elected roles of every workspace this host's app servers own. Empty from a
340    /// bridge older than 2.1, which means a full mesh.
341    #[serde(default)]
342    pub roles: Vec<WorkspaceRoles>,
343}
344
345impl PeersResult {
346    /// The roles for `workspace_id`, if the bridge elected any.
347    pub fn roles_for(&self, workspace_id: GrainId) -> Option<&WorkspaceRoles> {
348        self.roles.iter().find(|r| r.workspace_id == workspace_id)
349    }
350}
351
352/// One row of the routing table.
353#[derive(Clone, Debug, Deserialize, Serialize)]
354pub struct RouteStatus {
355    /// The workspace.
356    pub workspace_id: GrainId,
357    /// The application that owns it.
358    pub app_name: String,
359    /// Where it lives on this host.
360    pub root: PathBuf,
361    /// Whether that application's server is connected right now.
362    pub owner_online: bool,
363}
364
365/// The workgroup this host belongs to.
366#[derive(Clone, Debug, Deserialize, Serialize)]
367pub struct WorkgroupStatus {
368    /// Its id.
369    pub workgroup_id: GrainId,
370    /// Its name.
371    pub name: String,
372    /// How many non-retired devices it has.
373    pub devices: usize,
374}
375
376/// The embedding model a bridge serves.
377#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
378pub struct EmbedModelInfo {
379    /// The model's name.
380    pub model: String,
381    /// Its vector dimension.
382    pub dimension: u32,
383    /// The version of the text template applied before embedding.
384    pub template_version: u32,
385    /// The model's pinned revision, when the bridge pins one.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub revision: Option<String>,
388    /// Content tokens kept before embedding, when the bridge truncates (the local model).
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub max_tokens: Option<u32>,
391    /// Whether the model embeds images too ([`EMBED_IMAGES`]). Absent from bridges before
392    /// 2.4: false.
393    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
394    pub images: bool,
395}
396
397/// Result of [`EMBED_INFO`].
398#[derive(Clone, Debug, Default, Deserialize, Serialize)]
399pub struct EmbedInfoResult {
400    /// Whether this bridge embeds at all.
401    pub enabled: bool,
402    /// The model, when enabled.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub model: Option<EmbedModelInfo>,
405    /// Whether the model is in memory right now (local provider); always true for REST.
406    #[serde(default)]
407    pub loaded: bool,
408    /// Why embedding is off, or what it lacks while on. Absent from bridges before 2.3.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub note: Option<EmbedNote>,
411}
412
413/// Parameters of [`EMBED`].
414#[derive(Clone, Debug, Deserialize, Serialize)]
415pub struct EmbedParams {
416    /// The texts to embed.
417    pub texts: Vec<String>,
418}
419
420/// Parameters of [`EMBED_IMAGES`].
421#[derive(Clone, Debug, Deserialize, Serialize)]
422pub struct EmbedImagesParams {
423    /// The images, each a file's bytes (PNG, JPEG, WebP or GIF) in standard base64.
424    pub images: Vec<String>,
425}
426
427impl EmbedImagesParams {
428    /// The parameters for `images`, each a file's bytes.
429    pub fn new<B: AsRef<[u8]>>(images: &[B]) -> EmbedImagesParams {
430        use base64::Engine as _;
431        EmbedImagesParams {
432            images: images
433                .iter()
434                .map(|i| base64::engine::general_purpose::STANDARD.encode(i))
435                .collect(),
436        }
437    }
438
439    /// The images' bytes; the error names the first that is not base64.
440    pub fn decode(&self) -> Result<Vec<Vec<u8>>, String> {
441        use base64::Engine as _;
442        self.images
443            .iter()
444            .enumerate()
445            .map(|(i, b64)| {
446                base64::engine::general_purpose::STANDARD
447                    .decode(b64)
448                    .map_err(|e| format!("image {i}: {e}"))
449            })
450            .collect()
451    }
452}
453
454/// Result of [`EMBED`] and [`EMBED_IMAGES`].
455#[derive(Clone, Debug, Deserialize, Serialize)]
456pub struct EmbedResult {
457    /// The model that produced the vectors.
458    pub model: String,
459    /// Their dimension.
460    pub dimension: u32,
461    /// One vector per input, in order.
462    pub vectors: Vec<Vec<f32>>,
463}
464
465/// Result of [`STATUS`].
466#[derive(Clone, Debug, Deserialize, Serialize)]
467pub struct StatusResult {
468    /// The bridge's version.
469    pub version: String,
470    /// This host's iroh node id.
471    pub node_id: String,
472    /// The workgroup, if this host has joined one.
473    pub workgroup: Option<WorkgroupStatus>,
474    /// Every registered workspace.
475    pub routes: Vec<RouteStatus>,
476    /// The embedding service, when this bridge reports one.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub embedding: Option<EmbedInfoResult>,
479}
480
481/// Parameters of the [`INCOMING`] notification.
482#[derive(Clone, Debug, Deserialize, Serialize)]
483pub struct IncomingParams {
484    /// The workspace the peer asked for.
485    pub workspace_id: GrainId,
486    /// Which device asked.
487    pub peer_device_id: GrainId,
488    /// A single-use token naming the waiting stream.
489    ///
490    /// Useless to anyone who did not receive this notification, and consumed the first time
491    /// it is presented.
492    pub ticket: String,
493}
494
495/// The first line of a data-plane connection.
496#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
497#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
498pub enum DataHeader {
499    /// The app server wants a stream to `device_id` for `workspace_id`.
500    Open {
501        /// The workspace being synced.
502        workspace_id: GrainId,
503        /// The peer to reach.
504        device_id: GrainId,
505    },
506    /// The app server is answering a [`INCOMING`] notification.
507    Accept {
508        /// The ticket from that notification.
509        ticket: String,
510    },
511}
512
513/// The bridge's answer to a [`DataHeader`], sent as one line before the raw bytes begin.
514#[derive(Clone, Debug, Deserialize, Serialize)]
515pub struct DataAck {
516    /// Whether the stream is open.
517    pub ok: bool,
518    /// Why not, when it is not.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub error: Option<String>,
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(v: &T) -> T {
528        serde_json::from_value(serde_json::to_value(v).unwrap()).unwrap()
529    }
530
531    fn id() -> GrainId {
532        GrainId::random()
533    }
534
535    #[test]
536    fn embed_info_round_trips() {
537        let info = EmbedInfoResult {
538            enabled: true,
539            model: Some(EmbedModelInfo {
540                model: "m".into(),
541                dimension: 384,
542                template_version: 1,
543                revision: None,
544                max_tokens: None,
545                images: false,
546            }),
547            loaded: true,
548            note: Some(EmbedNote::KeyMissing),
549        };
550        let back = round_trip(&info);
551        assert_eq!(back.note, Some(EmbedNote::KeyMissing));
552        assert!(back.enabled && back.loaded);
553        assert_eq!(back.model, info.model);
554        let off = serde_json::to_value(EmbedInfoResult::default()).unwrap();
555        assert_eq!(off, serde_json::json!({"enabled": false, "loaded": false}));
556    }
557
558    #[test]
559    fn status_without_embedding_deserializes() {
560        let json = serde_json::json!({
561            "version": "1", "node_id": "n", "workgroup": null, "routes": []
562        });
563        let status: StatusResult = serde_json::from_value(json).unwrap();
564        assert!(status.embedding.is_none());
565    }
566
567    #[test]
568    fn embed_params_wire_shape() {
569        let v = serde_json::to_value(EmbedParams {
570            texts: vec!["a".into()],
571        })
572        .unwrap();
573        assert_eq!(v, serde_json::json!({"texts": ["a"]}));
574        let r = round_trip(&EmbedResult {
575            model: "m".into(),
576            dimension: 2,
577            vectors: vec![vec![1.0, 2.0]],
578        });
579        assert_eq!(r.vectors, vec![vec![1.0, 2.0]]);
580    }
581
582    #[test]
583    fn registration_round_trips() {
584        let params = RegisterParams {
585            app_name: "sapphire-journal".into(),
586            exe_path: "/usr/bin/sapphire-journal".into(),
587            managed_by: ManagedBy::Spawned,
588            workspaces: vec![WorkspaceRegistration {
589                workspace_id: id(),
590                root: "/home/me/journal".into(),
591            }],
592        };
593        let back = round_trip(&params);
594        assert_eq!(back.app_name, "sapphire-journal");
595        assert_eq!(back.workspaces.len(), 1);
596    }
597
598    #[test]
599    fn invite_params_carry_the_cli_arguments() {
600        let params = InviteParams {
601            name: "phone".into(),
602            ttl: Some(600),
603            workgroup: None,
604        };
605        // Serialisation shape is the contract with the bridge's handler; pin it.
606        let json = serde_json::to_value(&params).unwrap();
607        assert_eq!(json["name"], "phone");
608        assert_eq!(json["ttl"], 600);
609        assert!(json.get("workgroup").is_none());
610    }
611
612    #[test]
613    fn workspaces_result_round_trips() {
614        let raw = serde_json::json!({ "workspaces": [] });
615        let parsed: WorkspacesResult = serde_json::from_value(raw).unwrap();
616        assert!(parsed.workspaces.is_empty());
617    }
618
619    #[test]
620    fn an_open_header_round_trips_and_is_tagged() {
621        let header = DataHeader::Open {
622            workspace_id: id(),
623            device_id: id(),
624        };
625        let value = serde_json::to_value(&header).unwrap();
626        assert_eq!(value["kind"], "open");
627        assert_eq!(round_trip(&header), header);
628    }
629
630    #[test]
631    fn an_accept_header_round_trips_and_is_tagged() {
632        let header = DataHeader::Accept {
633            ticket: "t-123".into(),
634        };
635        let value = serde_json::to_value(&header).unwrap();
636        assert_eq!(value["kind"], "accept");
637        assert_eq!(round_trip(&header), header);
638    }
639
640    #[test]
641    fn an_unknown_header_kind_is_refused() {
642        let value = serde_json::json!({ "kind": "sideways" });
643        assert!(serde_json::from_value::<DataHeader>(value).is_err());
644    }
645
646    #[test]
647    fn a_data_ack_carries_its_reason_when_it_fails() {
648        let ack = DataAck {
649            ok: false,
650            error: Some("no such workspace".into()),
651        };
652        assert_eq!(round_trip(&ack).error.as_deref(), Some("no such workspace"));
653    }
654
655    #[test]
656    fn method_names_are_namespaced() {
657        for name in [
658            REGISTER, UNREGISTER, PEERS, STATUS, INCOMING, INVITE, JOIN, WORKSPACES,
659        ] {
660            assert!(name.starts_with("bridge."), "{name}");
661        }
662    }
663
664    #[test]
665    fn the_crate_stays_free_of_the_workspace_and_network_stacks() {
666        let manifest = include_str!("../Cargo.toml");
667        for forbidden in [
668            "iroh",
669            "sapphire-framework-workspace",
670            "sapphire-framework-retrieve",
671            "sapphire-framework-backend",
672        ] {
673            assert!(
674                !manifest.contains(forbidden),
675                "sapphire-framework-bridge-api must not depend on {forbidden}"
676            );
677        }
678    }
679
680    #[test]
681    fn the_api_version_is_the_crate_major() {
682        let major: u32 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap();
683        assert_eq!(API_VERSION, major);
684        assert_eq!(API_VERSION, 2);
685    }
686
687    #[test]
688    fn workgroup_create_and_device_retire_wire_shapes() {
689        let p = WorkgroupCreateParams {
690            name: "home".into(),
691            device_name: "desk".into(),
692        };
693        assert_eq!(
694            serde_json::to_value(&p).unwrap(),
695            serde_json::json!({ "name": "home", "device_name": "desk" })
696        );
697        let p = DeviceRetireParams {
698            selector: "laptop".into(),
699        };
700        assert_eq!(
701            serde_json::to_value(&p).unwrap(),
702            serde_json::json!({ "selector": "laptop" })
703        );
704        let id = GrainId::random();
705        let r = WorkgroupCreateResult {
706            workgroup_id: id,
707            name: "home".into(),
708            device_id: id,
709        };
710        assert_eq!(round_trip(&r).name, "home");
711        assert_eq!(WORKGROUP_CREATE, "bridge.workgroup_create");
712        assert_eq!(DEVICE_RETIRE, "bridge.device_retire");
713    }
714
715    #[test]
716    fn a_2_0_peers_answer_reads_with_no_roles_and_default_priority() {
717        let id = GrainId::random();
718        let old = serde_json::json!({
719            "peers": [{ "device_id": id, "name": "desk", "node_id": "", "connected": true }]
720        });
721
722        let read: PeersResult = serde_json::from_value(old).unwrap();
723
724        assert!(read.roles.is_empty());
725        assert_eq!(read.peers[0].priority, DEFAULT_PRIORITY);
726        assert_eq!(read.peers[0].availability, None);
727        assert!(read.roles_for(id).is_none());
728    }
729
730    #[test]
731    fn roles_for_finds_the_workspace() {
732        let ws = GrainId::random();
733        let d = GrainId::random();
734        let result = PeersResult {
735            peers: Vec::new(),
736            roles: vec![WorkspaceRoles {
737                workspace_id: ws,
738                primary: Some(d),
739                secondary: None,
740            }],
741        };
742
743        assert_eq!(result.roles_for(ws).unwrap().primary, Some(d));
744    }
745
746    #[test]
747    fn device_priority_set_params_are_selector_and_priority() {
748        let p = DevicePrioritySetParams {
749            selector: "desk".into(),
750            priority: 0,
751        };
752        assert_eq!(
753            serde_json::to_value(&p).unwrap(),
754            serde_json::json!({ "selector": "desk", "priority": 0 })
755        );
756    }
757}