Skip to main content

systemprompt_models/bridge/
instance.rs

1//! Per-instance bridge wire shapes: the public discovery document, the
2//! instance branding and the release window an instance admits.
3//!
4//! Discovery is served unauthenticated at
5//! `/.well-known/systemprompt-bridge` so a freshly installed bridge can find
6//! the instance's sign-in and identity routes before it holds a token. It
7//! carries no trust material: the manifest signing key still arrives through
8//! managed policy or an operator pin. Branding from discovery is presentation
9//! only and yields to the branding inside the signed manifest once one exists.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use serde::{Deserialize, Serialize};
15
16use crate::bridge::host::HostKind;
17
18pub const DISCOVERY_CONTRACT: &str = "1";
19
20/// The document served at `/.well-known/systemprompt-bridge`.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct BridgeDiscovery {
23    pub contract: String,
24    pub device_link_path: String,
25    pub identity_path: String,
26    #[serde(default)]
27    pub branding: InstanceBranding,
28    #[serde(default)]
29    pub enabled_hosts: Vec<HostKind>,
30}
31
32/// What a bridge shows for an instance: window, tray and sign-in copy.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct InstanceBranding {
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub display_name: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub logo_url: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub theme_css_url: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub sign_in_hint: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub docs_url: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub contact_email: Option<String>,
47}
48
49impl InstanceBranding {
50    #[must_use]
51    pub fn is_empty(&self) -> bool {
52        *self == Self::default()
53    }
54}
55
56/// The bridge release versions an instance admits, compared by strict semver
57/// precedence against the bridge's release (brand) version.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct BridgeVersionWindow {
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub min: Option<semver::Version>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub max: Option<semver::Version>,
64    #[serde(default)]
65    pub enforce_max: bool,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum WindowStatus {
71    Below,
72    Within,
73    Above,
74    Unknown,
75}
76
77impl BridgeVersionWindow {
78    #[must_use]
79    pub const fn is_open(&self) -> bool {
80        self.min.is_none() && self.max.is_none()
81    }
82
83    #[must_use]
84    pub fn admits(&self, version: &semver::Version) -> bool {
85        self.min
86            .as_ref()
87            .is_none_or(|min| version.cmp_precedence(min).is_ge())
88            && self
89                .max
90                .as_ref()
91                .is_none_or(|max| version.cmp_precedence(max).is_le())
92    }
93
94    #[must_use]
95    pub fn status(&self, reported: &str) -> WindowStatus {
96        let Ok(version) = semver::Version::parse(reported) else {
97            return WindowStatus::Unknown;
98        };
99        if self
100            .min
101            .as_ref()
102            .is_some_and(|min| version.cmp_precedence(min).is_lt())
103        {
104            WindowStatus::Below
105        } else if self
106            .max
107            .as_ref()
108            .is_some_and(|max| version.cmp_precedence(max).is_gt())
109        {
110            WindowStatus::Above
111        } else {
112            WindowStatus::Within
113        }
114    }
115
116    #[must_use]
117    pub fn intersect(&self, other: &Self) -> Self {
118        let min = match (&self.min, &other.min) {
119            (Some(a), Some(b)) => Some(if a.cmp_precedence(b).is_ge() {
120                a.clone()
121            } else {
122                b.clone()
123            }),
124            (a, b) => a.clone().or_else(|| b.clone()),
125        };
126        let max = match (&self.max, &other.max) {
127            (Some(a), Some(b)) => Some(if a.cmp_precedence(b).is_le() {
128                a.clone()
129            } else {
130                b.clone()
131            }),
132            (a, b) => a.clone().or_else(|| b.clone()),
133        };
134        Self {
135            min,
136            max,
137            enforce_max: self.enforce_max || other.enforce_max,
138        }
139    }
140
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        matches!((&self.min, &self.max), (Some(min), Some(max)) if min.cmp_precedence(max).is_gt())
144    }
145}
146
147/// Whether a bridge is carrying its user's traffic or has been switched off
148/// by its user, as reported on the heartbeat.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum BridgeState {
152    Active,
153    Disabled,
154}
155
156impl BridgeState {
157    #[must_use]
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::Active => "active",
161            Self::Disabled => "disabled",
162        }
163    }
164}
165
166impl WindowStatus {
167    #[must_use]
168    pub const fn as_str(self) -> &'static str {
169        match self {
170            Self::Below => "below",
171            Self::Within => "within",
172            Self::Above => "above",
173            Self::Unknown => "unknown",
174        }
175    }
176}