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)
480 lines
14 KiB
Rust
480 lines
14 KiB
Rust
//! 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);
|
|
}
|
|
}
|