lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }