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