sapphire_framework_bridge_api/
external.rs1use serde::{Deserialize, Serialize};
9
10use crate::{ApiKey, GrainId};
11
12#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
14pub struct ExternalDeviceInfo {
15 pub id: GrainId,
17 pub name: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub description: Option<String>,
22 #[serde(default)]
24 pub apps: Vec<String>,
25 pub created_at: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub rotated_at: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub retired_at: Option<String>,
33}
34
35#[derive(Clone, Debug, Default, Deserialize, Serialize)]
37pub struct ExternalDeviceListResult {
38 pub external_devices: Vec<ExternalDeviceInfo>,
40}
41
42#[derive(Clone, Debug, Deserialize, Serialize)]
44pub struct ExternalDeviceAddParams {
45 pub name: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub description: Option<String>,
50 pub apps: Vec<String>,
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize)]
56pub struct ExternalDeviceSelectParams {
57 pub selector: String,
59}
60
61#[derive(Clone, Debug, Deserialize, Serialize)]
63pub struct ExternalDeviceSetAppsParams {
64 pub selector: String,
66 pub apps: Vec<String>,
68}
69
70#[derive(Clone, Debug, Deserialize, Serialize)]
74pub struct ExternalDeviceTokenResult {
75 pub external_device: ExternalDeviceInfo,
77 pub token: ApiKey,
79}
80
81#[derive(Clone, Debug, Deserialize, Serialize)]
84pub struct ExternalDeviceAuthenticateParams {
85 pub token: ApiKey,
87 pub app: String,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
94pub struct ExternalDeviceAuthenticateResult {
95 pub id: GrainId,
97 pub name: String,
99}
100
101#[derive(Clone, Debug)]
103pub enum ExternalDeviceRequest {
104 List,
106 Add(ExternalDeviceAddParams),
108 Retire(String),
110 Restore(String),
112 Rotate(String),
114 SetApps(ExternalDeviceSetAppsParams),
116}
117
118#[derive(Clone, Debug)]
120pub enum ExternalDeviceOutcome {
121 List(Vec<ExternalDeviceInfo>),
123 One(ExternalDeviceInfo),
125 WithToken(ExternalDeviceTokenResult),
127}
128
129pub 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
144pub 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}