feat(p2p): add MeshNode integrating all production modules
New mesh_node.rs providing a production-ready node: - MeshNodeBuilder for fluent configuration - MeshConfig integration for all settings - MeshMetrics tracking for all operations - Rate limiting on incoming messages - Backpressure controller - Graceful shutdown via ShutdownCoordinator - Optional FappRouter based on capabilities - MeshRouter for envelope routing - TransportManager for multi-transport support Key APIs: - MeshNodeBuilder::new().fapp_relay().build() - node.process_incoming() with rate limiting + metrics - node.gc() for store/routing table cleanup - node.shutdown() for graceful termination 222 tests passing (203 lib + 3 fapp_flow + 16 multi_node)
This commit is contained in:
479
crates/meshservice/src/services/fapp.rs
Normal file
479
crates/meshservice/src/services/fapp.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
//! FAPP — Free Appointment Propagation Protocol.
|
||||
//!
|
||||
//! Decentralized psychotherapy appointment discovery.
|
||||
//!
|
||||
//! ## Flow
|
||||
//!
|
||||
//! 1. Therapist announces available slots (specialism, location, modality).
|
||||
//! 2. Announcement floods through mesh (TTL-limited, signature-verified).
|
||||
//! 3. Patient queries for matching slots (specialism, distance).
|
||||
//! 4. Relays respond with cached matches.
|
||||
//! 5. Patient reserves slot (E2E encrypted to therapist).
|
||||
//! 6. Therapist confirms/rejects.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ServiceError;
|
||||
use crate::message::{MessageType, ServiceMessage};
|
||||
use crate::router::{HandlerContext, ServiceAction, ServiceHandler};
|
||||
use crate::service_ids::FAPP;
|
||||
use crate::store::StoredMessage;
|
||||
use crate::wire::{decode_payload, encode_payload};
|
||||
|
||||
/// Therapy specialisms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Specialism {
|
||||
GeneralPsychotherapy = 0x01,
|
||||
CognitiveBehavioral = 0x02,
|
||||
Psychoanalysis = 0x03,
|
||||
SystemicTherapy = 0x04,
|
||||
TraumaFocused = 0x05,
|
||||
ChildAndAdolescent = 0x06,
|
||||
CoupleAndFamily = 0x07,
|
||||
Addiction = 0x08,
|
||||
Neuropsychology = 0x09,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for Specialism {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0x01 => Ok(Self::GeneralPsychotherapy),
|
||||
0x02 => Ok(Self::CognitiveBehavioral),
|
||||
0x03 => Ok(Self::Psychoanalysis),
|
||||
0x04 => Ok(Self::SystemicTherapy),
|
||||
0x05 => Ok(Self::TraumaFocused),
|
||||
0x06 => Ok(Self::ChildAndAdolescent),
|
||||
0x07 => Ok(Self::CoupleAndFamily),
|
||||
0x08 => Ok(Self::Addiction),
|
||||
0x09 => Ok(Self::Neuropsychology),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Therapy modality.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Modality {
|
||||
InPerson = 0x01,
|
||||
VideoCall = 0x02,
|
||||
PhoneCall = 0x03,
|
||||
TextBased = 0x04,
|
||||
}
|
||||
|
||||
/// Slot announcement payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotAnnounce {
|
||||
/// Therapist's specialisms (bitfield).
|
||||
pub specialisms: u16,
|
||||
/// Modality (bitfield).
|
||||
pub modality: u8,
|
||||
/// Postal code (first 3 digits for privacy).
|
||||
pub postal_prefix: String,
|
||||
/// Geohash (6 chars, ~1.2km precision).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geohash: Option<String>,
|
||||
/// Available slots count.
|
||||
pub available_slots: u8,
|
||||
/// Earliest available date (days from epoch).
|
||||
pub earliest_days: u16,
|
||||
/// Insurance types accepted (bitfield).
|
||||
pub insurance: u8,
|
||||
/// Optional profile URL for verification.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_url: Option<String>,
|
||||
/// Optional display name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl SlotAnnounce {
|
||||
/// Create a new announcement.
|
||||
pub fn new(specialisms: &[Specialism], modality: Modality, postal_prefix: &str) -> Self {
|
||||
let spec_bits = specialisms.iter().fold(0u16, |acc, s| acc | (1 << (*s as u8)));
|
||||
|
||||
Self {
|
||||
specialisms: spec_bits,
|
||||
modality: modality as u8,
|
||||
postal_prefix: postal_prefix.into(),
|
||||
geohash: None,
|
||||
available_slots: 1,
|
||||
earliest_days: 0,
|
||||
insurance: 0xFF, // All accepted by default
|
||||
profile_url: None,
|
||||
display_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set geohash location.
|
||||
pub fn with_geohash(mut self, geohash: &str) -> Self {
|
||||
self.geohash = Some(geohash[..6.min(geohash.len())].into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set available slots count.
|
||||
pub fn with_slots(mut self, count: u8) -> Self {
|
||||
self.available_slots = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set earliest availability.
|
||||
pub fn with_earliest(mut self, days_from_now: u16) -> Self {
|
||||
self.earliest_days = days_from_now;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set profile URL.
|
||||
pub fn with_profile(mut self, url: &str) -> Self {
|
||||
self.profile_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set display name.
|
||||
pub fn with_name(mut self, name: &str) -> Self {
|
||||
self.display_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a specialism is offered.
|
||||
pub fn has_specialism(&self, spec: Specialism) -> bool {
|
||||
self.specialisms & (1 << (spec as u8)) != 0
|
||||
}
|
||||
|
||||
/// Encode to CBOR bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_payload(self)
|
||||
}
|
||||
|
||||
/// Decode from CBOR bytes.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, ServiceError> {
|
||||
decode_payload(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Insurance types.
|
||||
pub mod insurance {
|
||||
pub const PRIVATE: u8 = 0x01;
|
||||
pub const PUBLIC: u8 = 0x02;
|
||||
pub const SELF_PAY: u8 = 0x04;
|
||||
}
|
||||
|
||||
/// Slot query payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotQuery {
|
||||
/// Desired specialisms (bitfield, any match).
|
||||
pub specialisms: u16,
|
||||
/// Postal prefix to search.
|
||||
pub postal_prefix: String,
|
||||
/// Max distance in km (optional).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_distance_km: Option<u8>,
|
||||
/// Required modality (0 = any).
|
||||
pub modality: u8,
|
||||
/// Max wait in days.
|
||||
pub max_wait_days: u16,
|
||||
/// Insurance type required.
|
||||
pub insurance: u8,
|
||||
}
|
||||
|
||||
impl SlotQuery {
|
||||
/// Create a query for a specialism in a postal area.
|
||||
pub fn new(specialism: Specialism, postal_prefix: &str) -> Self {
|
||||
Self {
|
||||
specialisms: 1 << (specialism as u8),
|
||||
postal_prefix: postal_prefix.into(),
|
||||
max_distance_km: None,
|
||||
modality: 0,
|
||||
max_wait_days: 365,
|
||||
insurance: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Require specific modality.
|
||||
pub fn with_modality(mut self, modality: Modality) -> Self {
|
||||
self.modality = modality as u8;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max wait time.
|
||||
pub fn with_max_wait(mut self, days: u16) -> Self {
|
||||
self.max_wait_days = days;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if an announce matches this query.
|
||||
pub fn matches(&self, announce: &SlotAnnounce) -> bool {
|
||||
// Specialism overlap
|
||||
if announce.specialisms & self.specialisms == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Postal prefix
|
||||
if !announce.postal_prefix.starts_with(&self.postal_prefix)
|
||||
&& !self.postal_prefix.starts_with(&announce.postal_prefix)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Modality
|
||||
if self.modality != 0 && announce.modality & self.modality == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait time
|
||||
if announce.earliest_days > self.max_wait_days {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Insurance
|
||||
if announce.insurance & self.insurance == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Available slots
|
||||
announce.available_slots > 0
|
||||
}
|
||||
|
||||
/// Encode to CBOR bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_payload(self)
|
||||
}
|
||||
|
||||
/// Decode from CBOR bytes.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, ServiceError> {
|
||||
decode_payload(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// FAPP service handler.
|
||||
pub struct FappService {
|
||||
/// Whether this node is a therapist (can announce).
|
||||
pub is_provider: bool,
|
||||
/// Whether this node relays FAPP messages.
|
||||
pub is_relay: bool,
|
||||
}
|
||||
|
||||
impl FappService {
|
||||
/// Create a new FAPP handler.
|
||||
pub fn new(is_provider: bool, is_relay: bool) -> Self {
|
||||
Self {
|
||||
is_provider,
|
||||
is_relay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a relay-only handler.
|
||||
pub fn relay() -> Self {
|
||||
Self::new(false, true)
|
||||
}
|
||||
|
||||
/// Create a provider handler.
|
||||
pub fn provider() -> Self {
|
||||
Self::new(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceHandler for FappService {
|
||||
fn service_id(&self) -> u32 {
|
||||
FAPP
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"FAPP"
|
||||
}
|
||||
|
||||
fn handle(
|
||||
&self,
|
||||
message: &ServiceMessage,
|
||||
context: &HandlerContext,
|
||||
) -> Result<ServiceAction, ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => {
|
||||
// Validate payload
|
||||
let _announce = SlotAnnounce::from_bytes(&message.payload)?;
|
||||
|
||||
// Store and forward if we're a relay
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::StoreAndForward)
|
||||
} else {
|
||||
Ok(ServiceAction::Store)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Query => {
|
||||
// Parse query
|
||||
let query = SlotQuery::from_bytes(&message.payload)?;
|
||||
|
||||
// Find matches in store
|
||||
let matches: Vec<_> = context
|
||||
.store
|
||||
.by_service(FAPP)
|
||||
.into_iter()
|
||||
.filter(|stored| {
|
||||
if stored.message.message_type != MessageType::Announce {
|
||||
return false;
|
||||
}
|
||||
if let Ok(announce) = SlotAnnounce::from_bytes(&stored.message.payload) {
|
||||
query.matches(&announce)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If we have matches, we could respond (simplified for now)
|
||||
if !matches.is_empty() {
|
||||
// In a real impl, we'd aggregate and send response
|
||||
Ok(ServiceAction::Handled)
|
||||
} else if self.is_relay {
|
||||
Ok(ServiceAction::ForwardOnly)
|
||||
} else {
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Reserve | MessageType::Confirm | MessageType::Cancel => {
|
||||
// E2E encrypted, just forward
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::ForwardOnly)
|
||||
} else {
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Revoke => {
|
||||
// Remove from store
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
|
||||
_ => Ok(ServiceAction::Drop),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self, message: &ServiceMessage) -> Result<(), ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => {
|
||||
SlotAnnounce::from_bytes(&message.payload)?;
|
||||
}
|
||||
MessageType::Query => {
|
||||
SlotQuery::from_bytes(&message.payload)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_query(&self, announce: &StoredMessage, query_msg: &ServiceMessage) -> bool {
|
||||
let Ok(announce_data) = SlotAnnounce::from_bytes(&announce.message.payload) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(query) = SlotQuery::from_bytes(&query_msg.payload) else {
|
||||
return false;
|
||||
};
|
||||
query.matches(&announce_data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a FAPP announce message.
|
||||
pub fn create_announce(
|
||||
identity: &crate::ServiceIdentity,
|
||||
announce: &SlotAnnounce,
|
||||
sequence: u64,
|
||||
) -> Result<ServiceMessage, ServiceError> {
|
||||
let payload = announce.to_bytes()?;
|
||||
Ok(ServiceMessage::announce(identity, FAPP, payload, sequence))
|
||||
}
|
||||
|
||||
/// Helper to create a FAPP query message.
|
||||
pub fn create_query(
|
||||
identity: &crate::ServiceIdentity,
|
||||
query: &SlotQuery,
|
||||
) -> Result<ServiceMessage, ServiceError> {
|
||||
let payload = query.to_bytes()?;
|
||||
Ok(ServiceMessage::query(identity, FAPP, payload))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::ServiceIdentity;
|
||||
|
||||
#[test]
|
||||
fn slot_announce_roundtrip() {
|
||||
let announce = SlotAnnounce::new(
|
||||
&[Specialism::CognitiveBehavioral, Specialism::TraumaFocused],
|
||||
Modality::VideoCall,
|
||||
"104",
|
||||
)
|
||||
.with_slots(3)
|
||||
.with_profile("https://therapists.de/dr-mueller");
|
||||
|
||||
let bytes = announce.to_bytes().unwrap();
|
||||
let decoded = SlotAnnounce::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert!(decoded.has_specialism(Specialism::CognitiveBehavioral));
|
||||
assert!(decoded.has_specialism(Specialism::TraumaFocused));
|
||||
assert!(!decoded.has_specialism(Specialism::Addiction));
|
||||
assert_eq!(decoded.available_slots, 3);
|
||||
assert_eq!(
|
||||
decoded.profile_url,
|
||||
Some("https://therapists.de/dr-mueller".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_matches_announce() {
|
||||
let announce = SlotAnnounce::new(
|
||||
&[Specialism::CognitiveBehavioral],
|
||||
Modality::InPerson,
|
||||
"104",
|
||||
)
|
||||
.with_slots(2);
|
||||
|
||||
let matching_query = SlotQuery::new(Specialism::CognitiveBehavioral, "104");
|
||||
assert!(matching_query.matches(&announce));
|
||||
|
||||
let wrong_spec = SlotQuery::new(Specialism::Addiction, "104");
|
||||
assert!(!wrong_spec.matches(&announce));
|
||||
|
||||
let wrong_location = SlotQuery::new(Specialism::CognitiveBehavioral, "200");
|
||||
assert!(!wrong_location.matches(&announce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_message_helpers() {
|
||||
let id = ServiceIdentity::generate();
|
||||
|
||||
let announce = SlotAnnounce::new(&[Specialism::GeneralPsychotherapy], Modality::VideoCall, "10");
|
||||
let msg = create_announce(&id, &announce, 1).unwrap();
|
||||
assert_eq!(msg.service_id, FAPP);
|
||||
assert_eq!(msg.message_type, MessageType::Announce);
|
||||
|
||||
let query = SlotQuery::new(Specialism::GeneralPsychotherapy, "10");
|
||||
let msg = create_query(&id, &query).unwrap();
|
||||
assert_eq!(msg.service_id, FAPP);
|
||||
assert_eq!(msg.message_type, MessageType::Query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fapp_handler_processes_announce() {
|
||||
use crate::router::ServiceRouter;
|
||||
use crate::capabilities;
|
||||
|
||||
let mut router = ServiceRouter::new(capabilities::RELAY);
|
||||
router.register(Box::new(FappService::relay()));
|
||||
|
||||
let id = ServiceIdentity::generate();
|
||||
let announce = SlotAnnounce::new(&[Specialism::TraumaFocused], Modality::InPerson, "100");
|
||||
let msg = create_announce(&id, &announce, 1).unwrap();
|
||||
|
||||
let action = router.handle(msg.clone(), Some(id.public_key())).unwrap();
|
||||
assert!(matches!(action, ServiceAction::StoreAndForward));
|
||||
|
||||
// Should be stored
|
||||
assert_eq!(router.store().service_count(FAPP), 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user