use serde::{Deserialize, Serialize};
use crate::{ApiKey, GrainId};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct ExternalDeviceInfo {
pub id: GrainId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub apps: Vec<String>,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ExternalDeviceListResult {
pub external_devices: Vec<ExternalDeviceInfo>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExternalDeviceAddParams {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub apps: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExternalDeviceSelectParams {
pub selector: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExternalDeviceSetAppsParams {
pub selector: String,
pub apps: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExternalDeviceTokenResult {
pub external_device: ExternalDeviceInfo,
pub token: ApiKey,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExternalDeviceAuthenticateParams {
pub token: ApiKey,
pub app: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct ExternalDeviceAuthenticateResult {
pub id: GrainId,
pub name: String,
}
#[derive(Clone, Debug)]
pub enum ExternalDeviceRequest {
List,
Add(ExternalDeviceAddParams),
Retire(String),
Restore(String),
Rotate(String),
SetApps(ExternalDeviceSetAppsParams),
}
#[derive(Clone, Debug)]
pub enum ExternalDeviceOutcome {
List(Vec<ExternalDeviceInfo>),
One(ExternalDeviceInfo),
WithToken(ExternalDeviceTokenResult),
}
pub fn describe_external_device(d: &ExternalDeviceInfo) -> String {
let apps = if d.apps.is_empty() {
"no apps".to_owned()
} else {
d.apps.join(", ")
};
let state = if d.retired_at.is_some() {
" (retired)"
} else {
""
};
format!("{} {} [{apps}]{state}", d.name, d.id)
}
pub fn describe_external_outcome(outcome: &ExternalDeviceOutcome) -> Vec<String> {
match outcome {
ExternalDeviceOutcome::List(list) if list.is_empty() => {
vec!["no external devices".to_owned()]
}
ExternalDeviceOutcome::List(list) => list.iter().map(describe_external_device).collect(),
ExternalDeviceOutcome::One(d) => vec![describe_external_device(d)],
ExternalDeviceOutcome::WithToken(r) => vec![
describe_external_device(&r.external_device),
format!("token: {}", r.token.expose()),
"This token is shown once. Give it to the external device now.".to_owned(),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info() -> ExternalDeviceInfo {
ExternalDeviceInfo {
id: GrainId::random(),
name: "pendant".into(),
description: None,
apps: vec!["sapphire-agent".into()],
created_at: "2026-10-11T00:00:00Z".into(),
rotated_at: None,
retired_at: None,
}
}
#[test]
fn tokens_never_print_in_debug() {
let r = ExternalDeviceTokenResult {
external_device: info(),
token: ApiKey::new("sapphire-ed-secret"),
};
assert!(!format!("{r:?}").contains("secret"));
let p = ExternalDeviceAuthenticateParams {
token: ApiKey::new("sapphire-ed-secret"),
app: "a".into(),
};
assert!(!format!("{p:?}").contains("secret"));
}
#[test]
fn the_token_is_printed_once_with_a_warning() {
let lines = describe_external_outcome(&ExternalDeviceOutcome::WithToken(
ExternalDeviceTokenResult {
external_device: info(),
token: ApiKey::new("sapphire-ed-xyz"),
},
));
assert_eq!(lines[1], "token: sapphire-ed-xyz");
let listed =
describe_external_outcome(&ExternalDeviceOutcome::List(vec![ExternalDeviceInfo {
retired_at: Some("2026-10-12T00:00:00Z".into()),
..info()
}]));
assert!(
listed[0].ends_with("[sapphire-agent] (retired)"),
"{}",
listed[0]
);
}
}