From 3018a2084389df0fd54e92f5507dfe906ad83dcf Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 2 Jun 2026 17:50:59 +0300 Subject: [PATCH] fix(plc): allow arbitrary services to sign Lewis: May this revision serve well! --- crates/tranquil-api/src/identity/plc/mod.rs | 2 +- crates/tranquil-api/src/identity/plc/sign.rs | 26 +--- crates/tranquil-pds/src/plc/mod.rs | 144 +++++++++++++++++-- 3 files changed, 139 insertions(+), 33 deletions(-) diff --git a/crates/tranquil-api/src/identity/plc/mod.rs b/crates/tranquil-api/src/identity/plc/mod.rs index 6ca9ec4..1e2f733 100644 --- a/crates/tranquil-api/src/identity/plc/mod.rs +++ b/crates/tranquil-api/src/identity/plc/mod.rs @@ -3,5 +3,5 @@ mod sign; mod submit; pub use request::request_plc_operation_signature; -pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation}; +pub use sign::{SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation}; pub use submit::{SubmitPlcOperationInput, submit_plc_operation}; diff --git a/crates/tranquil-api/src/identity/plc/sign.rs b/crates/tranquil-api/src/identity/plc/sign.rs index 169557c..4d144ab 100644 --- a/crates/tranquil-api/src/identity/plc/sign.rs +++ b/crates/tranquil-api/src/identity/plc/sign.rs @@ -9,7 +9,7 @@ use tranquil_pds::api::ApiError; use tranquil_pds::api::error::DbResultExt; use tranquil_pds::auth::{Auth, Permissive}; use tranquil_pds::circuit_breaker::with_circuit_breaker; -use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation}; +use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation}; use tranquil_pds::state::AppState; #[derive(Debug, Deserialize)] @@ -19,14 +19,7 @@ pub struct SignPlcOperationInput { pub rotation_keys: Option>, pub also_known_as: Option>, pub verification_methods: Option>, - pub services: Option>, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct ServiceInput { - #[serde(rename = "type")] - pub service_type: ServiceType, - pub endpoint: String, + pub services: Option>, } #[derive(Debug, Serialize)] @@ -106,25 +99,12 @@ pub async fn sign_plc_operation( if last_op.is_tombstone() { return Err(ApiError::from(PlcError::Tombstoned)); } - let services = input.services.map(|s| { - s.into_iter() - .map(|(k, v)| { - ( - k, - PlcService { - service_type: v.service_type, - endpoint: v.endpoint, - }, - ) - }) - .collect() - }); let unsigned_op = create_update_op( &last_op, input.rotation_keys, input.verification_methods, input.also_known_as, - services, + input.services, ) .map_err(|e| match e { PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()), diff --git a/crates/tranquil-pds/src/plc/mod.rs b/crates/tranquil-pds/src/plc/mod.rs index 2b76b82..cba9754 100644 --- a/crates/tranquil-pds/src/plc/mod.rs +++ b/crates/tranquil-pds/src/plc/mod.rs @@ -39,27 +39,77 @@ pub enum PlcOpType { Tombstone, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Error)] +#[error("service type must not be empty")] +pub struct EmptyServiceType; + +mod custom_service_type { + use super::EmptyServiceType; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct CustomServiceType(String); + + impl CustomServiceType { + pub(super) fn new(name: String) -> Result { + match name.as_str() { + "" => Err(EmptyServiceType), + _ => Ok(Self(name)), + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } +} + +pub use custom_service_type::CustomServiceType; + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ServiceType { - #[serde(rename = "AtprotoPersonalDataServer")] Pds, - #[serde(rename = "AtprotoAppView")] - AppView, - #[serde(rename = "AtprotoLabeler")] Labeler, + Other(CustomServiceType), } impl ServiceType { - pub fn as_str(self) -> &'static str { + pub fn as_str(&self) -> &str { match self { Self::Pds => "AtprotoPersonalDataServer", - Self::AppView => "AtprotoAppView", Self::Labeler => "AtprotoLabeler", + Self::Other(name) => name.as_str(), } } +} - pub fn is_pds(self) -> bool { - matches!(self, Self::Pds) +impl TryFrom for ServiceType { + type Error = EmptyServiceType; + + fn try_from(name: String) -> Result { + match name.as_str() { + "AtprotoPersonalDataServer" => Ok(Self::Pds), + "AtprotoLabeler" => Ok(Self::Labeler), + _ => CustomServiceType::new(name).map(Self::Other), + } + } +} + +impl Serialize for ServiceType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ServiceType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + Self::try_from(name).map_err(serde::de::Error::custom) } } @@ -625,4 +675,80 @@ mod tests { let signed = sign_operation(&op, &key).unwrap(); assert!(signed.get("sig").is_some()); } + + #[test] + fn test_service_type_known_round_trip() { + let cases = [ + (ServiceType::Pds, "\"AtprotoPersonalDataServer\""), + (ServiceType::Labeler, "\"AtprotoLabeler\""), + ]; + cases.iter().for_each(|(variant, encoded)| { + assert_eq!(serde_json::to_string(variant).unwrap(), *encoded); + assert_eq!( + serde_json::from_str::(encoded).unwrap(), + *variant + ); + }); + } + + #[test] + fn test_service_type_custom_round_trips() { + let parsed: ServiceType = serde_json::from_str("\"ConchFeedGenerator\"").unwrap(); + assert_eq!( + parsed, + ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap() + ); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + "\"ConchFeedGenerator\"" + ); + } + + #[test] + fn test_service_type_custom_normalizes_known_names() { + assert_eq!( + ServiceType::try_from("AtprotoPersonalDataServer".to_string()).unwrap(), + ServiceType::Pds + ); + assert_eq!( + ServiceType::try_from("AtprotoLabeler".to_string()).unwrap(), + ServiceType::Labeler + ); + } + + #[test] + fn test_appview_is_not_a_named_type() { + assert_eq!( + ServiceType::try_from("AtprotoAppView".to_string()).unwrap(), + ServiceType::Other(CustomServiceType::new("AtprotoAppView".to_string()).unwrap()) + ); + } + + #[test] + fn test_service_type_rejects_empty() { + assert!(ServiceType::try_from(String::new()).is_err()); + assert!(serde_json::from_str::("\"\"").is_err()); + } + + #[test] + fn test_plc_operation_with_custom_service_round_trips() { + let op_json = json!({ + "type": "plc_operation", + "rotationKeys": ["did:key:zScallop"], + "verificationMethods": { "atproto": "did:key:zUni" }, + "alsoKnownAs": ["at://whelk.nel.pet"], + "services": { + "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://nel.pet" }, + "custom_feedgen": { "type": "ConchFeedGenerator", "endpoint": "https://feed.nel.pet" } + }, + "prev": null + }); + let op: PlcOperation = serde_json::from_value(op_json.clone()).unwrap(); + assert_eq!( + op.services["custom_feedgen"].service_type, + ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap() + ); + assert_eq!(op.services["atproto_pds"].service_type, ServiceType::Pds); + assert_eq!(serde_json::to_value(&op).unwrap(), op_json); + } }