admission.rs (21347B)
1 //! Typed admission for verified Radroots events. 2 //! 3 //! Admission consumes signature-verified inputs, selects an exact typed or 4 //! registry contract, and returns a value bound to that verified envelope. It 5 //! is deterministic and does not authorize an actor, mutate storage, or 6 //! publish an event. 7 8 #[cfg(not(feature = "std"))] 9 use alloc::boxed::Box; 10 11 use core::fmt; 12 13 use radroots_event::{ 14 contract::{ContractMatchError, ContractValidationError, EventContract}, 15 envelope::EventEnvelope, 16 envelope::kind::{ 17 KIND_CLASSIFIED_LISTING, KIND_COMMENT, KIND_DELETION_REQUEST, KIND_POST, KIND_PROFILE, 18 }, 19 }; 20 21 use crate::{ 22 comment::admission::{ 23 RadrootsAdmittedNip22CommentEvent, RadrootsNip22CommentAdmissionError, 24 admit_verified_nip22_comment_event, 25 }, 26 deletion::admission::{ 27 RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09DeletionAdmissionError, 28 admit_verified_nip09_deletion_request_event, 29 }, 30 food_availability::admission::{ 31 RadrootsAdmittedFoodAvailabilityEvent, RadrootsFoodAvailabilityAdmissionError, 32 RadrootsFoodAvailabilityAdmissionOutcome, admit_verified_food_availability_event, 33 }, 34 post::admission::{ 35 RadrootsAdmittedRootPostEvent, RadrootsPostAdmissionError, RadrootsPostAdmissionOutcome, 36 admit_verified_post_event, 37 }, 38 profile::admission::{ 39 RadrootsAdmittedProfileEvent, RadrootsProfileAdmissionError, admit_verified_profile_event, 40 }, 41 reply::admission::{ 42 RadrootsAdmittedNip10ReplyEvent, RadrootsNip10ReplyAdmissionError, 43 admit_thread_excluded_post_candidate, 44 }, 45 verification::{ 46 RadrootsContractValidatedEvent, RadrootsSignatureVerifiedEvent, validate_event_contract, 47 }, 48 }; 49 50 /// Typed comment admission. 51 pub mod comment; 52 /// Typed deletion admission and deterministic suppression evaluation. 53 pub mod deletion; 54 /// Typed food-availability admission and revision validation. 55 pub mod food_availability; 56 /// Typed post admission. 57 pub mod post; 58 /// Typed profile admission. 59 pub mod profile; 60 /// Typed reply admission. 61 pub mod reply; 62 63 #[doc(hidden)] 64 pub mod registry_v7; 65 pub use registry_v7::{RadrootsRegistryV7AdmissionDecision, admit_verified_event_registry_v7}; 66 67 /// A verified event admitted through its exact typed profile or full registry shape. 68 #[non_exhaustive] 69 #[derive(Clone, Debug, PartialEq)] 70 pub enum RadrootsAdmittedEvent { 71 Profile(RadrootsAdmittedProfileEvent), 72 RootPost(RadrootsAdmittedRootPostEvent), 73 Reply(RadrootsAdmittedNip10ReplyEvent), 74 Comment(Box<RadrootsAdmittedNip22CommentEvent>), 75 DeletionRequest(RadrootsAdmittedNip09DeletionRequestEvent), 76 FoodAvailability(Box<RadrootsAdmittedFoodAvailabilityEvent>), 77 ContractValidated(RadrootsContractValidatedEvent), 78 } 79 80 impl RadrootsAdmittedEvent { 81 pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent { 82 match self { 83 Self::Profile(event) => event.verified_event(), 84 Self::RootPost(event) => event.verified_event(), 85 Self::Reply(event) => event.verified_event(), 86 Self::Comment(event) => event.verified_event(), 87 Self::DeletionRequest(event) => event.verified_event(), 88 Self::FoodAvailability(event) => event.verified_event(), 89 Self::ContractValidated(event) => event.verified_event(), 90 } 91 } 92 93 pub fn event(&self) -> &EventEnvelope { 94 self.verified_event().event() 95 } 96 97 pub fn contract(&self) -> &'static EventContract { 98 match self { 99 Self::Profile(event) => event.contract(), 100 Self::RootPost(event) => event.contract(), 101 Self::Reply(event) => event.contract(), 102 Self::Comment(event) => event.contract(), 103 Self::DeletionRequest(event) => event.contract(), 104 Self::FoodAvailability(event) => event.contract(), 105 Self::ContractValidated(event) => event.contract(), 106 } 107 } 108 109 pub fn contract_id(&self) -> &'static str { 110 self.contract().id 111 } 112 113 pub fn into_verified_event(self) -> RadrootsSignatureVerifiedEvent { 114 match self { 115 Self::Profile(event) => event.into_parts().0, 116 Self::RootPost(event) => event.into_parts().0, 117 Self::Reply(event) => event.into_parts().0, 118 Self::Comment(event) => event.into_parts().0, 119 Self::DeletionRequest(event) => event.into_parts().0, 120 Self::FoodAvailability(event) => event.into_parts().0, 121 Self::ContractValidated(event) => event.into_verified_event(), 122 } 123 } 124 } 125 126 #[non_exhaustive] 127 #[derive(Clone, Debug, PartialEq, Eq)] 128 pub enum RadrootsEventAdmissionError { 129 ContractMatch(ContractMatchError), 130 ContractValidation(ContractValidationError), 131 Profile(RadrootsProfileAdmissionError), 132 Post(RadrootsPostAdmissionError), 133 Reply(RadrootsNip10ReplyAdmissionError), 134 Comment(RadrootsNip22CommentAdmissionError), 135 DeletionRequest(RadrootsNip09DeletionAdmissionError), 136 FoodAvailability(RadrootsFoodAvailabilityAdmissionError), 137 } 138 139 impl RadrootsEventAdmissionError { 140 pub const fn code(&self) -> &'static str { 141 match self { 142 Self::ContractMatch(ContractMatchError::UnsupportedKind(_)) => "unsupported_kind", 143 Self::ContractMatch(ContractMatchError::UnsupportedShape(_)) => "unsupported_shape", 144 Self::ContractMatch(ContractMatchError::AmbiguousShape(_)) => "ambiguous_shape", 145 Self::ContractValidation(error) => error.code(), 146 Self::Profile(error) => error.code(), 147 Self::Post(error) => error.code(), 148 Self::Reply(error) => error.code(), 149 Self::Comment(error) => error.code(), 150 Self::DeletionRequest(error) => error.code(), 151 Self::FoodAvailability(error) => error.code(), 152 } 153 } 154 } 155 156 impl fmt::Display for RadrootsEventAdmissionError { 157 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 158 match self { 159 Self::ContractMatch(ContractMatchError::UnsupportedKind(kind)) => { 160 write!(formatter, "event kind {kind} has no registered contract") 161 } 162 Self::ContractMatch(ContractMatchError::UnsupportedShape(kind)) => { 163 write!( 164 formatter, 165 "event kind {kind} has no supported contract shape" 166 ) 167 } 168 Self::ContractMatch(ContractMatchError::AmbiguousShape(kind)) => { 169 write!( 170 formatter, 171 "event kind {kind} matches multiple contract shapes" 172 ) 173 } 174 Self::ContractValidation(error) => { 175 write!( 176 formatter, 177 "event contract validation failed with code {}", 178 error.code() 179 ) 180 } 181 Self::Profile(error) => write!(formatter, "{error}"), 182 Self::Post(error) => write!(formatter, "{error}"), 183 Self::Reply(error) => write!(formatter, "{error}"), 184 Self::Comment(error) => write!(formatter, "{error}"), 185 Self::DeletionRequest(error) => write!(formatter, "{error}"), 186 Self::FoodAvailability(error) => write!(formatter, "{error}"), 187 } 188 } 189 } 190 191 #[cfg(feature = "std")] 192 impl std::error::Error for RadrootsEventAdmissionError { 193 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 194 match self { 195 Self::Profile(error) => Some(error), 196 Self::Post(error) => Some(error), 197 Self::Reply(error) => Some(error), 198 Self::Comment(error) => Some(error), 199 Self::DeletionRequest(error) => Some(error), 200 Self::FoodAvailability(error) => Some(error), 201 Self::ContractMatch(_) | Self::ContractValidation(_) => None, 202 } 203 } 204 } 205 206 /// Admits an already verified event through the exact typed or registry boundary. 207 pub fn admit_verified_event( 208 event: RadrootsSignatureVerifiedEvent, 209 ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> { 210 match event.event().kind_u32() { 211 KIND_PROFILE => admit_profile(event), 212 KIND_POST => admit_post_or_reply(event), 213 KIND_COMMENT => admit_verified_nip22_comment_event(event) 214 .map(|event| RadrootsAdmittedEvent::Comment(Box::new(event))) 215 .map_err(RadrootsEventAdmissionError::Comment), 216 KIND_DELETION_REQUEST => admit_verified_nip09_deletion_request_event(event) 217 .map(RadrootsAdmittedEvent::DeletionRequest) 218 .map_err(RadrootsEventAdmissionError::DeletionRequest), 219 KIND_CLASSIFIED_LISTING => admit_food_or_registry(event), 220 _ => admit_registry_contract(event), 221 } 222 } 223 224 fn admit_profile( 225 event: RadrootsSignatureVerifiedEvent, 226 ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> { 227 admit_verified_profile_event(event) 228 .map(RadrootsAdmittedEvent::Profile) 229 .map_err(RadrootsEventAdmissionError::Profile) 230 } 231 232 fn admit_post_or_reply( 233 event: RadrootsSignatureVerifiedEvent, 234 ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> { 235 match admit_verified_post_event(event).map_err(RadrootsEventAdmissionError::Post)? { 236 RadrootsPostAdmissionOutcome::Root(event) => Ok(RadrootsAdmittedEvent::RootPost(event)), 237 RadrootsPostAdmissionOutcome::ThreadExcluded(candidate) => { 238 admit_thread_excluded_post_candidate(candidate) 239 .map(RadrootsAdmittedEvent::Reply) 240 .map_err(RadrootsEventAdmissionError::Reply) 241 } 242 } 243 } 244 245 fn admit_food_or_registry( 246 event: RadrootsSignatureVerifiedEvent, 247 ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> { 248 match admit_verified_food_availability_event(event) 249 .map_err(RadrootsEventAdmissionError::FoodAvailability)? 250 { 251 RadrootsFoodAvailabilityAdmissionOutcome::Admitted(event) => { 252 Ok(RadrootsAdmittedEvent::FoodAvailability(event)) 253 } 254 RadrootsFoodAvailabilityAdmissionOutcome::Excluded(candidate) => { 255 admit_registry_contract(candidate.into_parts().0) 256 } 257 } 258 } 259 260 fn admit_registry_contract( 261 event: RadrootsSignatureVerifiedEvent, 262 ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> { 263 validate_event_contract(event) 264 .map(RadrootsAdmittedEvent::ContractValidated) 265 .map_err(map_contract_validation) 266 } 267 268 fn map_contract_validation(error: ContractValidationError) -> RadrootsEventAdmissionError { 269 match error { 270 ContractValidationError::ContractMatch { error } => { 271 RadrootsEventAdmissionError::ContractMatch(error) 272 } 273 error => RadrootsEventAdmissionError::ContractValidation(error), 274 } 275 } 276 277 #[cfg(test)] 278 mod tests { 279 use super::*; 280 use radroots_event::contract::{EventDiscriminator, all_event_contracts}; 281 282 #[test] 283 fn covers_the_exact_admission_only_registry_inventory() { 284 let mut actual = all_event_contracts() 285 .iter() 286 .filter(|contract| matches!(contract.discriminator, EventDiscriminator::AdmissionOnly)) 287 .map(|contract| contract.id); 288 289 for expected in [ 290 "radroots.social.update.v1", 291 "radroots.social.photo_update.v1", 292 "radroots.social.ask.v1", 293 "radroots.social.reply.v1", 294 "radroots.social.deletion_request.v1", 295 "radroots.social.comment.v1", 296 "radroots.food.availability.v1", 297 ] { 298 assert_eq!(actual.next(), Some(expected)); 299 } 300 assert_eq!(actual.next(), None); 301 } 302 303 #[test] 304 fn contract_match_error_codes_are_stable_and_distinct() { 305 for (error, code) in [ 306 ( 307 ContractMatchError::UnsupportedKind(65_535), 308 "unsupported_kind", 309 ), 310 ( 311 ContractMatchError::UnsupportedShape(KIND_CLASSIFIED_LISTING), 312 "unsupported_shape", 313 ), 314 ( 315 ContractMatchError::AmbiguousShape(KIND_CLASSIFIED_LISTING), 316 "ambiguous_shape", 317 ), 318 ] { 319 let error = RadrootsEventAdmissionError::ContractMatch(error); 320 assert_eq!(error.code(), code); 321 assert!(!error.to_string().is_empty()); 322 } 323 } 324 325 mod signed { 326 use super::*; 327 use crate::{ 328 test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_ALICE_SECRET_KEY_HEX}, 329 verification::verify_nip01_event, 330 }; 331 use nostr::secp256k1::Message; 332 use nostr::{Keys, SECP256K1}; 333 use radroots_event::{ 334 envelope::EventEnvelopeParts, envelope::kind::KIND_FOLLOW, 335 wire::compute_canonical_nip01_event_id, 336 }; 337 338 #[test] 339 fn routes_every_typed_profile_and_preserves_the_verified_envelope() { 340 let profile = admitted(100, KIND_PROFILE, vec![], "{}"); 341 assert!(matches!(&profile, RadrootsAdmittedEvent::Profile(_))); 342 assert_admitted(profile, "radroots.profile.metadata.v1"); 343 344 let post = admitted(101, KIND_POST, vec![], "Harvest update"); 345 assert!(matches!(&post, RadrootsAdmittedEvent::RootPost(_))); 346 assert_admitted(post, "radroots.social.update.v1"); 347 348 let reply = admitted( 349 102, 350 KIND_POST, 351 vec![vec![ 352 "e".into(), 353 "a".repeat(64), 354 String::new(), 355 "root".into(), 356 ]], 357 "Reply", 358 ); 359 assert!(matches!(&reply, RadrootsAdmittedEvent::Reply(_))); 360 assert_admitted(reply, "radroots.social.reply.v1"); 361 362 let comment = admitted(103, KIND_COMMENT, comment_tags(), "Comment"); 363 assert!(matches!(&comment, RadrootsAdmittedEvent::Comment(_))); 364 assert_admitted(comment, "radroots.social.comment.v1"); 365 366 let deletion = admitted( 367 104, 368 KIND_DELETION_REQUEST, 369 vec![vec!["e".into(), "a".repeat(64)]], 370 "Superseded", 371 ); 372 assert!(matches!( 373 &deletion, 374 RadrootsAdmittedEvent::DeletionRequest(_) 375 )); 376 assert_admitted(deletion, "radroots.social.deletion_request.v1"); 377 378 let food = admitted( 379 200, 380 KIND_CLASSIFIED_LISTING, 381 food_tags(), 382 "Carrots available this week.", 383 ); 384 assert!(matches!(&food, RadrootsAdmittedEvent::FoodAvailability(_))); 385 assert_admitted(food, "radroots.food.availability.v1"); 386 } 387 388 #[test] 389 fn generic_fallback_and_invalid_outcomes_remain_distinct() { 390 let generic = admitted(300, KIND_FOLLOW, vec![], "{}"); 391 assert!(matches!( 392 &generic, 393 RadrootsAdmittedEvent::ContractValidated(_) 394 )); 395 assert_admitted(generic, "radroots.social.follow_list.v1"); 396 397 let unsupported = admit(301, u32::from(u16::MAX), vec![], "unsupported") 398 .expect_err("unregistered kind must remain unsupported"); 399 assert!(matches!( 400 unsupported, 401 RadrootsEventAdmissionError::ContractMatch(ContractMatchError::UnsupportedKind(_)) 402 )); 403 404 let unsupported_listing = 405 admit(302, KIND_CLASSIFIED_LISTING, vec![], "generic listing") 406 .expect_err("generic NIP-99 shape must remain unsupported"); 407 assert!(matches!( 408 unsupported_listing, 409 RadrootsEventAdmissionError::ContractMatch(ContractMatchError::UnsupportedShape( 410 KIND_CLASSIFIED_LISTING 411 )) 412 )); 413 414 let tolerant_profile = admitted( 415 303, 416 KIND_PROFILE, 417 vec![vec!["p".into(), "invalid".into()]], 418 "{}", 419 ); 420 assert!(matches!( 421 &tolerant_profile, 422 RadrootsAdmittedEvent::Profile(_) 423 )); 424 assert_admitted(tolerant_profile, "radroots.profile.metadata.v1"); 425 426 let invalid_profile = admit(306, KIND_PROFILE, vec![], "not JSON") 427 .expect_err("invalid Profile metadata must fail at the typed boundary"); 428 assert!(matches!( 429 invalid_profile, 430 RadrootsEventAdmissionError::Profile(_) 431 )); 432 433 let invalid_reply = admit( 434 304, 435 KIND_POST, 436 vec![vec![ 437 "e".into(), 438 "invalid".into(), 439 String::new(), 440 "root".into(), 441 ]], 442 "Reply", 443 ) 444 .expect_err("thread candidate must fail at the Reply boundary"); 445 assert!(matches!( 446 invalid_reply, 447 RadrootsEventAdmissionError::Reply(_) 448 )); 449 450 let mut mixed_tags = food_tags(); 451 mixed_tags.push(vec!["radroots:bin".into(), "bin-1".into()]); 452 let mixed = admit(305, KIND_CLASSIFIED_LISTING, mixed_tags, "Mixed listing") 453 .expect_err("mixed classified-listing markers must fail typed admission"); 454 assert!(matches!( 455 &mixed, 456 RadrootsEventAdmissionError::FoodAvailability(_) 457 )); 458 assert_eq!(mixed.code(), "food_profile_ambiguous"); 459 } 460 461 fn admitted( 462 created_at: u64, 463 kind: u32, 464 tags: Vec<Vec<String>>, 465 content: &str, 466 ) -> RadrootsAdmittedEvent { 467 admit(created_at, kind, tags, content).expect("event must be admitted") 468 } 469 470 fn admit( 471 created_at: u64, 472 kind: u32, 473 tags: Vec<Vec<String>>, 474 content: &str, 475 ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> { 476 let verified = verify_nip01_event(signed_event(created_at, kind, tags, content)) 477 .expect("fixed signed event must verify"); 478 admit_verified_event(verified) 479 } 480 481 fn assert_admitted(event: RadrootsAdmittedEvent, expected_contract_id: &str) { 482 let expected_event = event.event().clone(); 483 assert_eq!(event.contract_id(), expected_contract_id); 484 assert_eq!(event.verified_event().event(), &expected_event); 485 assert_eq!(event.into_verified_event().into_event(), expected_event); 486 } 487 488 fn signed_event( 489 created_at: u64, 490 kind: u32, 491 tags: Vec<Vec<String>>, 492 content: &str, 493 ) -> EventEnvelope { 494 let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX) 495 .expect("fixed fixture secret key must parse"); 496 let author = keys.public_key().to_string(); 497 let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) 498 .expect("canonical event id"); 499 let nostr_id = nostr::EventId::from_hex(&id.to_hex()).expect("Nostr event id"); 500 let message = Message::from_digest(nostr_id.to_bytes()); 501 let signature = SECP256K1.sign_schnorr_no_aux_rand( 502 &message, 503 &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), 504 ); 505 506 EventEnvelope::new(EventEnvelopeParts { 507 id: id.into_string(), 508 author, 509 created_at, 510 kind, 511 tags, 512 content: content.into(), 513 sig: signature.to_string(), 514 }) 515 .expect("valid signed event envelope") 516 } 517 518 fn comment_tags() -> Vec<Vec<String>> { 519 vec![ 520 vec![ 521 "E".into(), 522 "a".repeat(64), 523 String::new(), 524 FIXTURE_ALICE_PUBLIC_KEY_HEX.into(), 525 ], 526 vec!["K".into(), KIND_CLASSIFIED_LISTING.to_string()], 527 vec!["P".into(), FIXTURE_ALICE_PUBLIC_KEY_HEX.into()], 528 vec![ 529 "e".into(), 530 "a".repeat(64), 531 String::new(), 532 FIXTURE_ALICE_PUBLIC_KEY_HEX.into(), 533 ], 534 vec!["k".into(), KIND_CLASSIFIED_LISTING.to_string()], 535 vec!["p".into(), FIXTURE_ALICE_PUBLIC_KEY_HEX.into()], 536 ] 537 } 538 539 fn food_tags() -> Vec<Vec<String>> { 540 vec![ 541 vec!["d".into(), "nantes-carrots".into()], 542 vec!["title".into(), "Nantes Carrots".into()], 543 vec!["summary".into(), "Fresh bunches".into()], 544 vec!["published_at".into(), "100".into()], 545 vec!["location".into(), "Central Saanich, BC".into()], 546 vec!["price".into(), "3".into(), "CAD".into()], 547 vec!["radroots:price_unit".into(), "lb".into()], 548 vec!["radroots:quantity".into(), "24".into(), "lb".into()], 549 vec!["status".into(), "active".into()], 550 ] 551 } 552 } 553 }