From dd2041df2070c8184294b8a2d1f102fb6b250a4c Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 1 Apr 2026 07:52:01 +0200 Subject: [PATCH] feat(fapp): add integration demo + update status examples/fapp_demo.rs: - Therapist publishes SlotAnnounce - Relay caches and handles query - Patient sends SlotQuery, gets response - Shows full FappRouter API flow docs/status.md: - Updated FAPP integration status - FappRouter now implemented - Remaining: multi-node test, SlotReserve/Confirm, LoRa --- crates/quicprochat-p2p/Cargo.toml | 4 + docs/status.md | 30 +++++-- examples/fapp_demo.rs | 142 ++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 examples/fapp_demo.rs diff --git a/crates/quicprochat-p2p/Cargo.toml b/crates/quicprochat-p2p/Cargo.toml index 323e521..fa131fd 100644 --- a/crates/quicprochat-p2p/Cargo.toml +++ b/crates/quicprochat-p2p/Cargo.toml @@ -39,3 +39,7 @@ thiserror = { workspace = true } [dev-dependencies] tempfile = "3" + +[[example]] +name = "fapp_demo" +path = "../../examples/fapp_demo.rs" diff --git a/docs/status.md b/docs/status.md index d35d79d..724474d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -19,19 +19,31 @@ - Location hint is PLZ only (e.g. "80331") — never exact address - Anti-spam: Approbation hash binding, signature verification, sequence-based dedup, rate limiting, TTL enforcement -### FAPP integration — next steps +### FAPP integration — status -**Current state:** Spec (`docs/specs/fapp-protocol.md`) and Rust module (`crates/quicprochat-p2p/src/fapp.rs`) with store, matching, and tests are in place. **FAPP is not integrated** — `mesh_router.rs` does not decode, route, or emit FAPP CBOR yet. +**2026-04-01: FappRouter implemented!** -**Next steps** -1. **Wire into `mesh_router`:** On incoming mesh traffic, recognize FAPP message types; verify signatures and hop rules per spec; update `FappStore`; forward SlotAnnounce/Query/Response where applicable; align with `announce.rs` capability bits (`CAP_FAPP_*`). -2. **Test on stack:** Multi-node TCP/mesh demo first (two+ nodes, therapist/relay/patient roles); then LoRa hardware when available (airtime, fragmentation, duty cycle). -3. **After wiring:** SlotReserve/SlotConfirm E2E (e.g. X25519), anonymous return-path for SlotQuery responses, per-therapist rate limits in `FappStore`. +New `fapp_router.rs` module: +- `FappAction` enum: Ignore, Dropped, Forward, QueryResponse +- Wire format: 1-byte tag (0x01-0x05) + CBOR body +- `FappRouter` struct with shared `RoutingTable` + `TransportManager` +- `handle_incoming()` decodes and dispatches FAPP frames +- `process_slot_announce()` with relay/flood logic (dedup, hop check, store, forward) +- `process_slot_query()` answers from local `FappStore` +- `broadcast_announce()` / `send_query()` for outbound floods +- `drain_pending_sends()` for async send integration +- 3 unit tests passing + +**Remaining steps** +1. **Integration test:** Multi-node demo (therapist → relay → patient flow) +2. **Wire to P2pNode:** Add `FappRouter` to `start_with_mesh()` or similar +3. **SlotReserve/SlotConfirm:** E2E encrypted reservation flow +4. **LoRa test:** Verify FAPP over constrained links **Definition of done** -- Router end-to-end path for FAPP matches spec (verify, dedup, TTL, forwarding semantics). -- Integration proof: announce → query → response over at least one multi-hop path (automated test or documented manual run). -- Hardware check: same scenario on LoRa, or a short note in status on what blocked it. +- announce → query → response works over multi-hop (automated or manual) +- SlotReserve/Confirm E2E encryption works +- LoRa test or documented blocker --- diff --git a/examples/fapp_demo.rs b/examples/fapp_demo.rs new file mode 100644 index 0000000..f26d807 --- /dev/null +++ b/examples/fapp_demo.rs @@ -0,0 +1,142 @@ +//! Minimal FAPP ([`quicprochat_p2p::fapp`]) demo: therapist publishes a slot, relay caches it, +//! patient floods a query; the relay answers from its in-memory store. +//! +//! Uses mock [`TransportAddr`] values and [`RoutingTable`](quicprochat_p2p::routing_table::RoutingTable) +//! seeds — no sockets or iroh. +//! +//! Run: `cargo run -p quicprochat-p2p --example fapp_demo` + +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use quicprochat_p2p::announce::{MeshAnnounce, CAP_RELAY}; +use quicprochat_p2p::fapp::{ + Fachrichtung, FappStore, Kostentraeger, Modalitaet, SlotAnnounce, SlotQuery, SlotType, + TimeSlot, CAP_FAPP_PATIENT, CAP_FAPP_RELAY, CAP_FAPP_THERAPIST, +}; +use quicprochat_p2p::fapp_router::{FappAction, FappRouter}; +use quicprochat_p2p::identity::MeshIdentity; +use quicprochat_p2p::routing_table::RoutingTable; +use quicprochat_p2p::transport::TransportAddr; +use quicprochat_p2p::transport_manager::TransportManager; + +/// Insert one synthetic route so [`FappRouter::broadcast_announce`] / [`FappRouter::send_query`] +/// have a next hop (see [`quicprochat_p2p::fapp_router::FappRouter::drain_pending_sends`]). +fn seed_mock_neighbor(table: &mut RoutingTable, next_hop: TransportAddr) { + let peer = MeshIdentity::generate(); + let mut announce = MeshAnnounce::with_sequence(&peer, CAP_RELAY, vec![], 8, 1); + announce.hop_count = 1; + let _ = table.update(&announce, "mock", next_hop); +} + +fn main() -> Result<()> { + let relay_inbox: TransportAddr = TransportAddr::Raw(b"link-therapist-to-relay".to_vec()); + let patient_outbound: TransportAddr = TransportAddr::Raw(b"link-patient-flood".to_vec()); + + // --- Therapist: can publish; routing table points at the "relay" hop --- + let therapist_routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + { + let mut table = therapist_routes + .write() + .map_err(|e| anyhow::anyhow!("therapist routes lock poisoned: {e}"))?; + seed_mock_neighbor(&mut table, relay_inbox.clone()); + } + let therapist = FappRouter::new( + FappStore::new(), + therapist_routes, + Arc::new(TransportManager::new()), + CAP_FAPP_THERAPIST, + ); + + // --- Patient + relay: start with an empty table so the announce is cached but not re-flooded --- + let patient_relay_routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + let patient_relay = FappRouter::new( + FappStore::new(), + Arc::clone(&patient_relay_routes), + Arc::new(TransportManager::new()), + CAP_FAPP_PATIENT | CAP_FAPP_RELAY, + ); + + let therapist_id = MeshIdentity::generate(); + let announce = SlotAnnounce::new( + &therapist_id, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis], + vec![Kostentraeger::GKV], + "80331".into(), + vec![TimeSlot { + start_unix: 1, + duration_minutes: 50, + slot_type: SlotType::Therapie, + }], + [0xAA; 32], + 1, + ); + + // 1) Therapist broadcasts SlotAnnounce (queued to mock relay address). + therapist + .broadcast_announce(announce.clone()) + .context("broadcast_announce")?; + let mut pending = therapist + .drain_pending_sends() + .context("therapist drain_pending_sends")?; + let (to_relay, announce_wire) = pending + .pop() + .context("expected one pending frame from therapist")?; + println!("Therapist queued announce -> {to_relay} ({} bytes)", announce_wire.len()); + assert_eq!(to_relay, relay_inbox); + + // 2) Relay receives the wire frame (in real code: [`TransportManager::send`] / recv). + let relay_action = patient_relay.handle_incoming(&announce_wire); + println!("Relay handled announce: {relay_action:?}"); + + // Add a mock neighbor so `send_query` can enqueue a flood (API demo only). + { + let mut table = patient_relay_routes + .write() + .map_err(|e| anyhow::anyhow!("patient/relay routes lock poisoned: {e}"))?; + seed_mock_neighbor(&mut table, patient_outbound.clone()); + } + + // 3) Patient floods a SlotQuery. + let query = SlotQuery { + query_id: [0x42; 16], + fachrichtung: Some(Fachrichtung::Verhaltenstherapie), + modalitaet: None, + kostentraeger: None, + plz_prefix: Some("803".into()), + earliest: None, + latest: None, + slot_type: None, + max_results: 5, + }; + patient_relay.send_query(query).context("send_query")?; + pending = patient_relay + .drain_pending_sends() + .context("patient drain_pending_sends")?; + let (flood_dest, query_wire) = pending + .pop() + .context("expected one pending query frame")?; + println!("Patient queued query flood -> {flood_dest} ({} bytes)", query_wire.len()); + assert_eq!(flood_dest, patient_outbound); + + // 4) Same relay node decodes the query and answers from [`FappStore`]. + match patient_relay.handle_incoming(&query_wire) { + FappAction::QueryResponse(resp) => { + println!( + "Relay query response: query_id={:02x?}.. matches={}", + &resp.query_id[..4], + resp.matches.len() + ); + let first = resp + .matches + .first() + .context("expected at least one matching announce")?; + assert_eq!(first.id, announce.id); + } + other => anyhow::bail!("expected QueryResponse, got {other:?}"), + } + + Ok(()) +}