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
This commit is contained in:
2026-04-01 07:52:01 +02:00
parent 65ce5aec18
commit dd2041df20
3 changed files with 167 additions and 9 deletions

View File

@@ -39,3 +39,7 @@ thiserror = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[[example]]
name = "fapp_demo"
path = "../../examples/fapp_demo.rs"

View File

@@ -19,19 +19,31 @@
- Location hint is PLZ only (e.g. "80331") — never exact address - 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 - 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** New `fapp_router.rs` module:
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_*`). - `FappAction` enum: Ignore, Dropped, Forward, QueryResponse
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). - Wire format: 1-byte tag (0x01-0x05) + CBOR body
3. **After wiring:** SlotReserve/SlotConfirm E2E (e.g. X25519), anonymous return-path for SlotQuery responses, per-therapist rate limits in `FappStore`. - `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** **Definition of done**
- Router end-to-end path for FAPP matches spec (verify, dedup, TTL, forwarding semantics). - announce → query → response works over multi-hop (automated or manual)
- Integration proof: announce → query → response over at least one multi-hop path (automated test or documented manual run). - SlotReserve/Confirm E2E encryption works
- Hardware check: same scenario on LoRa, or a short note in status on what blocked it. - LoRa test or documented blocker
--- ---

142
examples/fapp_demo.rs Normal file
View File

@@ -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(())
}