fix(plc): allow arbitrary services to sign

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-06-02 17:50:59 +03:00
committed by Tangled
parent 37fc06fb39
commit 3018a20843
3 changed files with 139 additions and 33 deletions
+1 -1
View File
@@ -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};
+3 -23
View File
@@ -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<Vec<String>>,
pub also_known_as: Option<Vec<String>>,
pub verification_methods: Option<HashMap<String, String>>,
pub services: Option<HashMap<String, ServiceInput>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceInput {
#[serde(rename = "type")]
pub service_type: ServiceType,
pub endpoint: String,
pub services: Option<HashMap<String, PlcService>>,
}
#[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()),
+135 -9
View File
@@ -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<Self, EmptyServiceType> {
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<String> for ServiceType {
type Error = EmptyServiceType;
fn try_from(name: String) -> Result<Self, Self::Error> {
match name.as_str() {
"AtprotoPersonalDataServer" => Ok(Self::Pds),
"AtprotoLabeler" => Ok(Self::Labeler),
_ => CustomServiceType::new(name).map(Self::Other),
}
}
}
impl Serialize for ServiceType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ServiceType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<ServiceType>(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::<ServiceType>("\"\"").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);
}
}