Skip to main content

sapphire_framework_bridge_api/
external.rs

1//! External devices on the control plane (#199): clients that reach the workgroup's
2//! applications with a key instead of syncing.
3//!
4//! The bridge owns the ledger (the workgroup root's `external_devices/`). An application's
5//! server asks it to [`authenticate`](crate::EXTERNAL_DEVICE_AUTHENTICATE) a presented token
6//! for its own app name; the CLIs and the GUI manage the records.
7
8use serde::{Deserialize, Serialize};
9
10use crate::{ApiKey, GrainId};
11
12/// One external device, as the ledger has it. Never carries the token or its hash.
13#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
14pub struct ExternalDeviceInfo {
15    /// Its id.
16    pub id: GrainId,
17    /// Its name, unique among external devices.
18    pub name: String,
19    /// A note for the user.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub description: Option<String>,
22    /// The applications it may use.
23    #[serde(default)]
24    pub apps: Vec<String>,
25    /// When it was added (RFC 3339).
26    pub created_at: String,
27    /// When its token was last replaced (RFC 3339).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub rotated_at: Option<String>,
30    /// When it was retired (RFC 3339), if it is.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub retired_at: Option<String>,
33}
34
35/// Result of [`EXTERNAL_DEVICE_LIST`](crate::EXTERNAL_DEVICE_LIST).
36#[derive(Clone, Debug, Default, Deserialize, Serialize)]
37pub struct ExternalDeviceListResult {
38    /// Every record, by name.
39    pub external_devices: Vec<ExternalDeviceInfo>,
40}
41
42/// Parameters of [`EXTERNAL_DEVICE_ADD`](crate::EXTERNAL_DEVICE_ADD).
43#[derive(Clone, Debug, Deserialize, Serialize)]
44pub struct ExternalDeviceAddParams {
45    /// Its name.
46    pub name: String,
47    /// A note.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub description: Option<String>,
50    /// The applications it may use.
51    pub apps: Vec<String>,
52}
53
54/// Parameters naming one record: retire, restore, rotate.
55#[derive(Clone, Debug, Deserialize, Serialize)]
56pub struct ExternalDeviceSelectParams {
57    /// Its name or id.
58    pub selector: String,
59}
60
61/// Parameters of [`EXTERNAL_DEVICE_SET_APPS`](crate::EXTERNAL_DEVICE_SET_APPS).
62#[derive(Clone, Debug, Deserialize, Serialize)]
63pub struct ExternalDeviceSetAppsParams {
64    /// Its name or id.
65    pub selector: String,
66    /// The applications it may use from now on.
67    pub apps: Vec<String>,
68}
69
70/// Result of [`EXTERNAL_DEVICE_ADD`](crate::EXTERNAL_DEVICE_ADD) and
71/// [`EXTERNAL_DEVICE_ROTATE`](crate::EXTERNAL_DEVICE_ROTATE): the record and its new token,
72/// which is never available again. `Debug` hides the token.
73#[derive(Clone, Debug, Deserialize, Serialize)]
74pub struct ExternalDeviceTokenResult {
75    /// The record.
76    pub external_device: ExternalDeviceInfo,
77    /// The token to give the external device.
78    pub token: ApiKey,
79}
80
81/// Parameters of [`EXTERNAL_DEVICE_AUTHENTICATE`](crate::EXTERNAL_DEVICE_AUTHENTICATE).
82/// `Debug` hides the token.
83#[derive(Clone, Debug, Deserialize, Serialize)]
84pub struct ExternalDeviceAuthenticateParams {
85    /// The token the client presented.
86    pub token: ApiKey,
87    /// The application asking, which the external device must be allowed to use.
88    pub app: String,
89}
90
91/// Result of [`EXTERNAL_DEVICE_AUTHENTICATE`](crate::EXTERNAL_DEVICE_AUTHENTICATE): who it
92/// is. A refusal is an error, never a result.
93#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
94pub struct ExternalDeviceAuthenticateResult {
95    /// Its id.
96    pub id: GrainId,
97    /// Its name.
98    pub name: String,
99}
100
101/// One management request, as either CLI builds it from its arguments.
102#[derive(Clone, Debug)]
103pub enum ExternalDeviceRequest {
104    /// List every record.
105    List,
106    /// Add one.
107    Add(ExternalDeviceAddParams),
108    /// Retire one.
109    Retire(String),
110    /// Restore one.
111    Restore(String),
112    /// Replace one's token.
113    Rotate(String),
114    /// Set one's applications.
115    SetApps(ExternalDeviceSetAppsParams),
116}
117
118/// What a management request answers.
119#[derive(Clone, Debug)]
120pub enum ExternalDeviceOutcome {
121    /// The records.
122    List(Vec<ExternalDeviceInfo>),
123    /// One record, changed.
124    One(ExternalDeviceInfo),
125    /// One record and the token to hand over, once.
126    WithToken(ExternalDeviceTokenResult),
127}
128
129/// One line describing a record: name, id, applications, state.
130pub fn describe_external_device(d: &ExternalDeviceInfo) -> String {
131    let apps = if d.apps.is_empty() {
132        "no apps".to_owned()
133    } else {
134        d.apps.join(", ")
135    };
136    let state = if d.retired_at.is_some() {
137        " (retired)"
138    } else {
139        ""
140    };
141    format!("{} {} [{apps}]{state}", d.name, d.id)
142}
143
144/// The lines either CLI prints for an outcome. A token is printed here, and only here.
145pub fn describe_external_outcome(outcome: &ExternalDeviceOutcome) -> Vec<String> {
146    match outcome {
147        ExternalDeviceOutcome::List(list) if list.is_empty() => {
148            vec!["no external devices".to_owned()]
149        }
150        ExternalDeviceOutcome::List(list) => list.iter().map(describe_external_device).collect(),
151        ExternalDeviceOutcome::One(d) => vec![describe_external_device(d)],
152        ExternalDeviceOutcome::WithToken(r) => vec![
153            describe_external_device(&r.external_device),
154            format!("token: {}", r.token.expose()),
155            "This token is shown once. Give it to the external device now.".to_owned(),
156        ],
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn info() -> ExternalDeviceInfo {
165        ExternalDeviceInfo {
166            id: GrainId::random(),
167            name: "pendant".into(),
168            description: None,
169            apps: vec!["sapphire-agent".into()],
170            created_at: "2026-10-11T00:00:00Z".into(),
171            rotated_at: None,
172            retired_at: None,
173        }
174    }
175
176    #[test]
177    fn tokens_never_print_in_debug() {
178        let r = ExternalDeviceTokenResult {
179            external_device: info(),
180            token: ApiKey::new("sapphire-ed-secret"),
181        };
182        assert!(!format!("{r:?}").contains("secret"));
183        let p = ExternalDeviceAuthenticateParams {
184            token: ApiKey::new("sapphire-ed-secret"),
185            app: "a".into(),
186        };
187        assert!(!format!("{p:?}").contains("secret"));
188    }
189
190    #[test]
191    fn the_token_is_printed_once_with_a_warning() {
192        let lines = describe_external_outcome(&ExternalDeviceOutcome::WithToken(
193            ExternalDeviceTokenResult {
194                external_device: info(),
195                token: ApiKey::new("sapphire-ed-xyz"),
196            },
197        ));
198        assert_eq!(lines[1], "token: sapphire-ed-xyz");
199        let listed =
200            describe_external_outcome(&ExternalDeviceOutcome::List(vec![ExternalDeviceInfo {
201                retired_at: Some("2026-10-12T00:00:00Z".into()),
202                ..info()
203            }]));
204        assert!(
205            listed[0].ends_with("[sapphire-agent] (retired)"),
206            "{}",
207            listed[0]
208        );
209    }
210}