1	//! Inbound dispatch E2E (D9-1-1): the serve half of every wire protocol runs
2	//! **undriven** — no probe brain hand-wires `serve_*`; the daemon-side
3	//! dispatcher claims each peer-initiated stream, classifies it by first
4	//! record, and serves it. This is the production behavior the D5–D8 E2E
5	//! suites deferred ("the daemon dispatch").
6	//!
7	//! Topology per test: two hermetic loopback brokers (requester A, responder
8	//! B). B runs `run_dispatch_loop` like the real daemon; B's gates load from
9	//! the **canonical** SPT_HOME stores (the production path), so each test
10	//! seeds subnet/trust/visibility files first. Tests serialize on one lock —
11	//! they share the process's SPT_HOME.
12
13	use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
14	use std::sync::{Arc, Mutex, OnceLock};
15	use std::thread;
16	use std::time::Duration;
17
18	use spt_daemon::brain::Brain;
19	use spt_daemon::broker::Broker;
20	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
21	use spt_daemon::nethost::{NetConfig, NetHost};
22	use spt_daemon::propagate::{request_update, UpdatePullOutcome};
23	use spt_daemon::relcache::ReleaseCache;
24	use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};
25	use spt_daemon::sync::request_sync;
26	use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI};
27	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
28	use spt_net::net::notif::NotifRecord;
29	use spt_net::net::wanmsg::WanMessage;
30	use spt_proto::identity::Identity;
31	use spt_store::contextstore::ContextStore;
32	use spt_store::info::{self, InfoJson};
33	use spt_store::notif::{NotifRow, NotifStore};
34	use spt_store::perch::{self, ParentHint};
35	use spt_store::spool;
36	use spt_store::subnet::SubnetStore;
37	use spt_store::trust::TrustStore;
38	use spt_store::visibility::VisibilityStore;
39	use tempfile::TempDir;
40
41	/// One SPT_HOME for the whole binary; tests serialize on [`LOCK`] because the
42	/// dispatcher's gates read the canonical stores under it.
43	static HOME: OnceLock<TempDir> = OnceLock::new();
44	static LOCK: Mutex<()> = Mutex::new(());
45	fn init_home() {
46	    HOME.get_or_init(|| {
47	        let dir = TempDir::new().expect("temp dir");
48	        std::env::set_var("SPT_HOME", dir.path());
49	        dir
50	    });
51	}
52
53	static SEQ: AtomicU32 = AtomicU32::new(0);
54	fn unique_name() -> String {
55	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
56	    format!("spt-daemon-d91-{}-{}.sock", std::process::id(), n)
57	}
58
59	static OP: AtomicU64 = AtomicU64::new(1);
60	fn op() -> u64 {
61	    OP.fetch_add(1, Ordering::Relaxed)
62	}
63
64	fn hermetic() -> NetConfig {
65	    NetConfig {
66	        identity: Identity::generate(),
67	        relay: RelayPolicy::Disabled,
68	        discovery: LocalDiscovery::Off,
69	        scope: BindScope::Loopback,
70	    }
71	}
72
73	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
74	    let host = NetHost::start(hermetic()).expect("net host start");
75	    let broker =
76	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
77	    let serve = Arc::clone(&broker);
78	    thread::spawn(move || {
79	        let _ = serve.serve();
80	    });
81	    broker
82	}
83
84	fn connect_retry(name: &str) -> Brain {
85	    for _ in 0..300 {
86	        if let Ok(b) = Brain::cold_start(name, 1) {
87	            return b;
88	        }
89	        thread::sleep(Duration::from_millis(10));
90	    }
91	    panic!("brain could not connect");
92	}
93
94	/// Stand B's dispatcher up exactly as the daemon does, returning its stop
95	/// flag (set on test exit so the loop thread winds down).
96	fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
97	    let stop = Arc::new(AtomicBool::new(false));
98	    let flag = Arc::clone(&stop);
99	    let name = broker_name.to_string();
100	    let home = perch::spt_home();
101	    let paths = DispatchPaths {
102	        owlery: perch::owlery_dir(),
103	        releases: home.join("releases"),
104	        scratch: scratch.to_path_buf(),
105	    };
106	    let registry = Arc::new(spt_daemon::registryhost::RegistryHost::new("b-dispatch"));
107	    thread::spawn(move || {
108	        let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
109	    });
110	    stop
111	}
112
113	/// Seed B's canonical gate stores: a subnet, A's pubkey trusted in it, and
114	/// (optionally) endpoint ids synced there. The dispatcher's serve arms load
115	/// these from SPT_HOME — the production read path.
116	fn seed_gates(subnet: &str, peer_hex: &str, synced_ids: &[&str]) {
117	    let mut subnets = SubnetStore::load();
118	    if !subnets.subnets.iter().any(|s| s.name == subnet) {
119	        subnets.create_subnet(subnet).expect("create subnet");
120	        subnets.save().expect("save subnets");
121	    }
122	    let mut trust = TrustStore::load();
123	    trust.record(subnet, peer_hex, None, None);
124	    trust.save().expect("save trust");
125	    let mut vis = VisibilityStore::load();
126	    for id in synced_ids {
127	        vis.set_sync_subnets(id, vec![subnet.to_string()]);
128	    }
129	    vis.save().expect("save visibility");
130	}
131
132	fn node_hex(brain: &mut Brain) -> String {
133	    brain.net_status().expect("status").node_id_hex.expect("net enabled")
134	}
135
136	/// Marker-stamped context body (the live write path stamps these in
137	/// production; the vector makes the merge driver's dominance decidable).
138	fn stamped(body: &str) -> String {
139	    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
140	}
141
142	// [int->REQ-INST-5] a sync pull is served END TO END by the dispatcher: A
143	// dials, requests, and receives the branch with NOBODY hand-wiring
144	// serve_sync at B. The gate ran from B's canonical stores.
145	#[test]
146	fn dispatcher_serves_a_sync_pull_undriven() {
147	    init_home();
148	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
149	    let dir = TempDir::new().unwrap();
150	    let (a_name, b_name) = (unique_name(), unique_name());
151	    let _a = net_broker(&a_name, dir.path());
152	    let _b = net_broker(&b_name, dir.path());
153	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
154
155	    // B's canonical context store holds the mind A will pull.
156	    let id = "ling-d91-sync";
157	    let branch = format!("a-{id}");
158	    let cs_b = ContextStore::open_or_init().expect("B context store");
159	    let p = cs_b.live_context_path(id).unwrap();
160	    std::fs::write(&p, stamped("the synced mind")).unwrap();
161	    cs_b.commit_live(id, "seed").unwrap();
162
163	    let mut a = connect_retry(&a_name);
164	    let mut b_probe = connect_retry(&b_name);
165	    seed_gates("d91-sync", &node_hex(&mut a), &[id]);
166
167	    let addr = b_probe.net_status().expect("status").addr;
168	    let conn = a.net_dial(addr, Some(op())).expect("dial");
169	    let cs_a_root = dir.path().join("a-ctx");
170	    let cs_a = ContextStore::open_or_init_in(&cs_a_root).expect("A context store");
171	    let report = request_sync(
172	        &mut a,
173	        conn.conn_id,
174	        std::slice::from_ref(&branch),
175	        op(),
176	        &cs_a,
177	        &dir.path().join("scratch-a"),
178	    )
179	    .expect("pull");
180
181	    assert!(
182	        report.applied.iter().any(|r| r.branch == branch && !r.accepted.is_empty()),
183	        "the dispatcher served the pull undriven: {report:?}"
184	    );
185	    let tip = cs_a.branch_store().tip(&branch).expect("tip query");
186	    assert!(tip.is_some(), "A holds the pulled branch");
187	    stop.store(true, Ordering::Relaxed);
188	}
189
190	// [int->REQ-UPD-1] an update pull is served end to end by the dispatcher:
191	// B stages a release, A queries/fetches/verifies/stages — no hand-wired
192	// serve_update. The trust gate ran from B's canonical TrustStore.
193	#[test]
194	fn dispatcher_serves_an_update_pull_undriven() {
195	    init_home();
196	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
197	    let dir = TempDir::new().unwrap();
198	    let (a_name, b_name) = (unique_name(), unique_name());
199	    let _a = net_broker(&a_name, dir.path());
200	    let _b = net_broker(&b_name, dir.path());
201	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
202
203	    // Stage v6 at B (in the canonical releases dir the dispatcher opens).
204	    use ed25519_dalek::{Signer, SigningKey};
205	    let sk = SigningKey::from_bytes(&[7u8; 32]);
206	    let artifact = b"dispatch-served binary".to_vec();
207	    let meta = ReleaseMetadata {
208	        version: 6,
209	        channel: "stable".to_string(),
210	        expires_at_ms: u64::MAX,
211	        key_id: "k".to_string(),
212	        artifact_sha256: sha256_hex(&artifact),
213	        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
214	        broker_resource_abi: BROKER_RESOURCE_ABI,
215	    };
216	    let metadata_json = serde_json::to_string(&meta).unwrap();
217	    let sig = sk.sign(metadata_json.as_bytes());
218	    let signed = SignedRelease {
219	        metadata_json,
220	        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
221	    };
222	    let releases_b = perch::spt_home().join("releases");
223	    ReleaseCache::open(&releases_b).stage(&signed, &artifact).expect("stage at B");
224
225	    let mut a = connect_retry(&a_name);
226	    let mut b_probe = connect_retry(&b_name);
227	    seed_gates("d91-upd", &node_hex(&mut a), &[]);
228
229	    let mut trusted = std::collections::BTreeMap::new();
230	    trusted.insert("k".to_string(), sk.verifying_key());
231	    let policy = VerifyPolicy {
232	        trusted_keys: trusted,
233	        revoked_keys: Default::default(),
234	        pinned_channel: "stable".to_string(),
235	        current_version: 5,
236	        now_ms: 1_000,
237	    };
238
239	    let addr = b_probe.net_status().expect("status").addr;
240	    let conn = a.net_dial(addr, Some(op())).expect("dial");
241	    let cache_a = ReleaseCache::open(&dir.path().join("releases-a"));
242	    let outcome = request_update(
243	        &mut a,
244	        conn.conn_id,
245	        op(),
246	        &BrokerAbi::current(),
247	        &policy,
248	        &cache_a,
249	        &dir.path().join("scratch-a"),
250	    )
251	    .expect("pull");
252
253	    assert!(
254	        matches!(outcome, UpdatePullOutcome::Updated(_)),
255	        "the dispatcher served the update undriven: {outcome:?}"
256	    );
257	    assert_eq!(
258	        cache_a.load_artifact().expect("staged bytes"),
259	        artifact,
260	        "byte-identical artifact staged at A through the full gate"
261	    );
262	    stop.store(true, Ordering::Relaxed);
263	}
264
265	// [int->REQ-NOTIF-1] a notif feed is applied end to end by the dispatcher:
266	// A pushes a row, B's canonical store converges — no hand-wired apply loop.
267	#[test]
268	fn dispatcher_applies_a_notif_feed_undriven() {
269	    init_home();
270	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
271	    let dir = TempDir::new().unwrap();
272	    let (a_name, b_name) = (unique_name(), unique_name());
273	    let _a = net_broker(&a_name, dir.path());
274	    let _b = net_broker(&b_name, dir.path());
275	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
276
277	    let mut a = connect_retry(&a_name);
278	    let mut b_probe = connect_retry(&b_name);
279	    seed_gates("d91-notif", &node_hex(&mut a), &[]);
280
281	    let row = NotifRow {
282	        notif_id: "aabb:42".to_string(),
283	        subnet: "d91-notif".to_string(),
284	        kind: "agent".to_string(),
285	        from_id: "oak@aabb".to_string(),
286	        body: "dispatched hello".to_string(),
287	        created_ms: 1_000,
288	        dismissed: false,
289	        seen: Default::default(),
290	        last_surfaced_ms: 0,
291	    };
292	    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });
293
294	    let addr = b_probe.net_status().expect("status").addr;
295	    let conn = a.net_dial(addr, Some(op())).expect("dial");
296	    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
297	    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");
298
299	    // The dispatcher applies asynchronously — poll B's canonical store.
300	    let store_b = NotifStore::open().expect("B notif store");
301	    let mut found = false;
302	    for _ in 0..400 {
303	        let rows = store_b.list("d91-notif").expect("list");
304	        if rows.iter().any(|r| r.notif_id == "aabb:42" && r.body == "dispatched hello") {
305	            found = true;
306	            break;
307	        }
308	        thread::sleep(Duration::from_millis(25));
309	    }
310	    assert!(found, "the dispatcher applied the feed into B's canonical store");
311	    stop.store(true, Ordering::Relaxed);
312	}
313
314	// [int->REQ-NET-1] a WAN message feed lands in B's perch spool end to end —
315	// and the direction fact is recorded right: A's table shows its opened
316	// stream as locally-initiated; B's shows the same stream as peer-initiated
317	// (what the dispatcher claimed it by).
318	#[test]
319	fn dispatcher_funnels_wan_messages_undriven() {
320	    init_home();
321	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
322	    let dir = TempDir::new().unwrap();
323	    let (a_name, b_name) = (unique_name(), unique_name());
324	    let _a = net_broker(&a_name, dir.path());
325	    let _b = net_broker(&b_name, dir.path());
326	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
327
328	    // An offline perch at B so the funnel takes the atomic spool path.
329	    let target = "ling-d91-wan";
330	    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
331	    std::fs::create_dir_all(&perch_path).expect("perch dir");
332	    let rec = InfoJson::new(target, "now", std::process::id(), "sess", "ready_agent");
333	    info::write_info(&perch_path, &rec).expect("info.json");
334
335	    let mut a = connect_retry(&a_name);
336	    let mut b_probe = connect_retry(&b_name);
337
338	    let addr = b_probe.net_status().expect("status").addr;
339	    let conn = a.net_dial(addr, Some(op())).expect("dial");
340	    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
341	    let msg = WanMessage {
342	        target: target.to_string(),
343	        from: "doyle".to_string(),
344	        body: "undriven hello".to_string(),
345	        op_id: "d91-wan-1".to_string(),
346	    };
347	    a.net_stream_send(opened.stream_id, &spt_net::net::ndjson::encode_line(&msg), None, true)
348	        .expect("send msg");
349
350	    // Direction fact, both sides of the same stream.
351	    let a_rows = a.net_streams().expect("A streams").streams;
352	    assert!(
353	        a_rows.iter().any(|s| s.stream_id == opened.stream_id && s.initiated_locally),
354	        "A's own open is locally-initiated in A's table"
355	    );
356	    let b_rows = b_probe.net_streams().expect("B streams").streams;
357	    assert!(
358	        b_rows.iter().all(|s| !s.initiated_locally),
359	        "every row at B is peer-initiated (B opened nothing)"
360	    );
361
362	    let mut spooled = false;
363	    for _ in 0..400 {
364	        let pending = spool::peek_all_at(&perch_path).expect("peek");
365	        if pending.iter().any(|(_, _, body, _)| body == "undriven hello") {
366	            spooled = true;
367	            break;
368	        }
369	        thread::sleep(Duration::from_millis(25));
370	    }
371	    assert!(spooled, "the dispatcher funneled the WAN message into the spool");
372	    stop.store(true, Ordering::Relaxed);
373	}
374
375	// [int->REQ-INST-8] [int->REQ-REACH-1] remote-drive is served end to end by
376	// the dispatcher: B hosts a LABELED session; A attaches by session id, types,
377	// and reads the echo — the gate subject came from the broker's table (the
378	// spawn label), not wire bytes. An UNLABELED session refuses, fail-closed.
379	#[test]
380	fn dispatcher_serves_a_remote_drive_attach_undriven() {
381	    init_home();
382	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
383	    let dir = TempDir::new().unwrap();
384	    let (a_name, b_name) = (unique_name(), unique_name());
385	    let _a = net_broker(&a_name, dir.path());
386	    let _b = net_broker(&b_name, dir.path());
387	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
388
389	    // B hosts one labeled echo session and one unlabeled one.
390	    #[cfg(unix)]
391	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
392	    #[cfg(windows)]
393	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
394	    let mut b_host = connect_retry(&b_name);
395	    let labeled = b_host
396	        .spawn_session(spt_daemon::msg::SpawnReq {
397	            program: program.clone(),
398	            args: args.clone(),
399	            rows: 24,
400	            cols: 80,
401	            endpoint: "ling-drive".to_string(),
402	        })
403	        .expect("spawn labeled");
404	    let unlabeled = b_host
405	        .spawn_session(spt_daemon::msg::SpawnReq {
406	            program,
407	            args,
408	            rows: 24,
409	            cols: 80,
410	            endpoint: String::new(),
411	        })
412	        .expect("spawn unlabeled");
413
414	    let mut a = connect_retry(&a_name);
415	    let mut b_probe = connect_retry(&b_name);
416	    let addr = b_probe.net_status().expect("status").addr;
417	    let conn = a.net_dial(addr, Some(op())).expect("dial");
418
419	    // Unlabeled session: the dispatcher refuses (our read side finishes
420	    // without a single Output record).
421	    let refused =
422	        spt_daemon::attach::request_attach(&mut a, conn.conn_id, unlabeled, 0, op())
423	            .expect("open refused viewport");
424	    a.net_stream_subscribe(refused, 0).expect("subscribe");
425	    loop {
426	        match a.read_event().expect("event") {
427	            spt_daemon::brain::BrokerEvent::NetStreamEof { stream_id, .. }
428	                if stream_id == refused =>
429	            {
430	                break; // refused: clean finish, no output ever
431	            }
432	            spt_daemon::brain::BrokerEvent::NetStreamData { stream_id, bytes, .. }
433	                if stream_id == refused && !bytes.is_empty() =>
434	            {
435	                panic!("an unlabeled session must never serve output");
436	            }
437	            _ => {}
438	        }
439	    }
440
441	    // Labeled session: attach, type, read the echo back — all undriven.
442	    let viewport =
443	        spt_daemon::attach::request_attach(&mut a, conn.conn_id, labeled, 0, op())
444	            .expect("open viewport");
445	    a.net_stream_subscribe(viewport, 0).expect("subscribe");
446	    spt_daemon::attach::send_attach_input(&mut a, viewport, b"dispatch-driven\r\n", op())
447	        .expect("type");
448	    let mut echoed = Vec::new();
449	    let deadline = std::time::Instant::now() + Duration::from_secs(10);
450	    let mut decoder = spt_net::net::attach::AttachDecoder::new();
451	    while std::time::Instant::now() < deadline {
452	        match a.read_event().expect("event") {
453	            spt_daemon::brain::BrokerEvent::NetStreamData { stream_id, bytes, .. }
454	                if stream_id == viewport =>
455	            {
456	                for rec in decoder.push(&bytes) {
457	                    if let spt_net::net::attach::AttachRecord::Output { data_b64, .. } = rec {
458	                        echoed.extend(
459	                            spt_daemon::msg::decode_bytes(&data_b64).expect("b64"),
460	                        );
461	                    }
462	                }
463	                if String::from_utf8_lossy(&echoed).contains("dispatch-driven") {
464	                    break;
465	                }
466	            }
467	            _ => {}
468	        }
469	    }
470	    assert!(
471	        String::from_utf8_lossy(&echoed).contains("dispatch-driven"),
472	        "the dispatcher served the viewport undriven; got {:?}",
473	        String::from_utf8_lossy(&echoed)
474	    );
475	    stop.store(true, Ordering::Relaxed);
476	}
477
478	// [int->REQ-REACH-1] a file fetch is served end to end by the dispatcher:
479	// the wire names the gating endpoint; the file pumps back and commits.
480	#[test]
481	fn dispatcher_serves_a_file_fetch_undriven() {
482	    init_home();
483	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
484	    let dir = TempDir::new().unwrap();
485	    let (a_name, b_name) = (unique_name(), unique_name());
486	    let _a = net_broker(&a_name, dir.path());
487	    let _b = net_broker(&b_name, dir.path());
488	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
489
490	    // The served file lives under B's canonical transfer root.
491	    let root = spt_store::xfer::transfers_dir();
492	    std::fs::create_dir_all(root.join("notes")).unwrap();
493	    std::fs::write(root.join("notes").join("d915.md"), b"fetched through dispatch").unwrap();
494
495	    let mut a = connect_retry(&a_name);
496	    let mut b_probe = connect_retry(&b_name);
497	    let addr = b_probe.net_status().expect("status").addr;
498	    let conn = a.net_dial(addr, Some(op())).expect("dial");
499
500	    let dest = dir.path().join("pulled.md");
501	    let n = spt_daemon::xfer::fetch_file(
502	        &mut a,
503	        conn.conn_id,
504	        "ling-files",
505	        "notes/d915.md",
506	        &dest,
507	        "d915-x1",
508	        op(),
509	        &dir.path().join("progress-a"),
510	    )
511	    .expect("fetch");
512	    assert_eq!(n, 24);
513	    assert_eq!(std::fs::read(&dest).unwrap(), b"fetched through dispatch");
514	    stop.store(true, Ordering::Relaxed);
515	}
516
517	// [unit->REQ-NET-1] an unclassifiable stream is dropped without wedging the
518	// dispatcher: garbage first, then a valid notif feed still converges.
519	#[test]
520	fn dispatcher_survives_an_unknown_stream() {
521	    init_home();
522	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
523	    let dir = TempDir::new().unwrap();
524	    let (a_name, b_name) = (unique_name(), unique_name());
525	    let _a = net_broker(&a_name, dir.path());
526	    let _b = net_broker(&b_name, dir.path());
527	    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));
528
529	    let mut a = connect_retry(&a_name);
530	    let mut b_probe = connect_retry(&b_name);
531	    seed_gates("d91-unk", &node_hex(&mut a), &[]);
532
533	    let addr = b_probe.net_status().expect("status").addr;
534	    let conn = a.net_dial(addr, Some(op())).expect("dial");
535
536	    // Garbage stream: not JSON at all.
537	    let junk = a.net_open_stream(conn.conn_id, Some(op())).expect("open junk");
538	    a.net_stream_send(junk.stream_id, b"this is not a protocol\n", None, true).expect("junk");
539
540	    // A valid feed afterward still lands — the loop did not die on the junk.
541	    let row = NotifRow {
542	        notif_id: "ccdd:7".to_string(),
543	        subnet: "d91-unk".to_string(),
544	        kind: "agent".to_string(),
545	        from_id: "oak@ccdd".to_string(),
546	        body: "after the junk".to_string(),
547	        created_ms: 1_000,
548	        dismissed: false,
549	        seen: Default::default(),
550	        last_surfaced_ms: 0,
551	    };
552	    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });
553	    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open feed");
554	    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");
555
556	    let store_b = NotifStore::open().expect("B notif store");
557	    let mut found = false;
558	    for _ in 0..400 {
559	        if store_b.list("d91-unk").expect("list").iter().any(|r| r.notif_id == "ccdd:7") {
560	            found = true;
561	            break;
562	        }
563	        thread::sleep(Duration::from_millis(25));
564	    }
565	    assert!(found, "the dispatcher survived the unknown stream and served the next one");
566	    stop.store(true, Ordering::Relaxed);
567	}
568

//! Inbound dispatch E2E (D9-1-1): the serve half of every wire protocol runs
//! **undriven** — no probe brain hand-wires `serve_*`; the daemon-side
//! dispatcher claims each peer-initiated stream, classifies it by first
//! record, and serves it. This is the production behavior the D5–D8 E2E
//! suites deferred ("the daemon dispatch").
//!
//! Topology per test: two hermetic loopback brokers (requester A, responder
//! B). B runs `run_dispatch_loop` like the real daemon; B's gates load from
//! the **canonical** SPT_HOME stores (the production path), so each test
//! seeds subnet/trust/visibility files first. Tests serialize on one lock —
//! they share the process's SPT_HOME.

use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{request_update, UpdatePullOutcome};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};
use spt_daemon::sync::request_sync;
use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::NotifRecord;
use spt_net::net::wanmsg::WanMessage;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

/// One SPT_HOME for the whole binary; tests serialize on [`LOCK`] because the
/// dispatcher's gates read the canonical stores under it.
static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d91-{}-{}.sock", std::process::id(), n)
}

static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Stand B's dispatcher up exactly as the daemon does, returning its stop
/// flag (set on test exit so the loop thread winds down).
fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
    let stop = Arc::new(AtomicBool::new(false));
    let flag = Arc::clone(&stop);
    let name = broker_name.to_string();
    let home = perch::spt_home();
    let paths = DispatchPaths {
        owlery: perch::owlery_dir(),
        releases: home.join("releases"),
        scratch: scratch.to_path_buf(),
    };
    let registry = Arc::new(spt_daemon::registryhost::RegistryHost::new("b-dispatch"));
    thread::spawn(move || {
        let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
    });
    stop
}

/// Seed B's canonical gate stores: a subnet, A's pubkey trusted in it, and
/// (optionally) endpoint ids synced there. The dispatcher's serve arms load
/// these from SPT_HOME — the production read path.
fn seed_gates(subnet: &str, peer_hex: &str, synced_ids: &[&str]) {
    let mut subnets = SubnetStore::load();
    if !subnets.subnets.iter().any(|s| s.name == subnet) {
        subnets.create_subnet(subnet).expect("create subnet");
        subnets.save().expect("save subnets");
    }
    let mut trust = TrustStore::load();
    trust.record(subnet, peer_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    for id in synced_ids {
        vis.set_sync_subnets(id, vec![subnet.to_string()]);
    }
    vis.save().expect("save visibility");
}

fn node_hex(brain: &mut Brain) -> String {
    brain.net_status().expect("status").node_id_hex.expect("net enabled")
}

/// Marker-stamped context body (the live write path stamps these in
/// production; the vector makes the merge driver's dominance decidable).
fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

// [int->REQ-INST-5] a sync pull is served END TO END by the dispatcher: A
// dials, requests, and receives the branch with NOBODY hand-wiring
// serve_sync at B. The gate ran from B's canonical stores.
#[test]
fn dispatcher_serves_a_sync_pull_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // B's canonical context store holds the mind A will pull.
    let id = "ling-d91-sync";
    let branch = format!("a-{id}");
    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path(id).unwrap();
    std::fs::write(&p, stamped("the synced mind")).unwrap();
    cs_b.commit_live(id, "seed").unwrap();

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-sync", &node_hex(&mut a), &[id]);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let cs_a_root = dir.path().join("a-ctx");
    let cs_a = ContextStore::open_or_init_in(&cs_a_root).expect("A context store");
    let report = request_sync(
        &mut a,
        conn.conn_id,
        std::slice::from_ref(&branch),
        op(),
        &cs_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    assert!(
        report.applied.iter().any(|r| r.branch == branch && !r.accepted.is_empty()),
        "the dispatcher served the pull undriven: {report:?}"
    );
    let tip = cs_a.branch_store().tip(&branch).expect("tip query");
    assert!(tip.is_some(), "A holds the pulled branch");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-UPD-1] an update pull is served end to end by the dispatcher:
// B stages a release, A queries/fetches/verifies/stages — no hand-wired
// serve_update. The trust gate ran from B's canonical TrustStore.
#[test]
fn dispatcher_serves_an_update_pull_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // Stage v6 at B (in the canonical releases dir the dispatcher opens).
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[7u8; 32]);
    let artifact = b"dispatch-served binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    let releases_b = perch::spt_home().join("releases");
    ReleaseCache::open(&releases_b).stage(&signed, &artifact).expect("stage at B");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-upd", &node_hex(&mut a), &[]);

    let mut trusted = std::collections::BTreeMap::new();
    trusted.insert("k".to_string(), sk.verifying_key());
    let policy = VerifyPolicy {
        trusted_keys: trusted,
        revoked_keys: Default::default(),
        pinned_channel: "stable".to_string(),
        current_version: 5,
        now_ms: 1_000,
    };

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let cache_a = ReleaseCache::open(&dir.path().join("releases-a"));
    let outcome = request_update(
        &mut a,
        conn.conn_id,
        op(),
        &BrokerAbi::current(),
        &policy,
        &cache_a,
        &dir.path().join("scratch-a"),
    )
    .expect("pull");

    assert!(
        matches!(outcome, UpdatePullOutcome::Updated(_)),
        "the dispatcher served the update undriven: {outcome:?}"
    );
    assert_eq!(
        cache_a.load_artifact().expect("staged bytes"),
        artifact,
        "byte-identical artifact staged at A through the full gate"
    );
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-NOTIF-1] a notif feed is applied end to end by the dispatcher:
// A pushes a row, B's canonical store converges — no hand-wired apply loop.
#[test]
fn dispatcher_applies_a_notif_feed_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-notif", &node_hex(&mut a), &[]);

    let row = NotifRow {
        notif_id: "aabb:42".to_string(),
        subnet: "d91-notif".to_string(),
        kind: "agent".to_string(),
        from_id: "oak@aabb".to_string(),
        body: "dispatched hello".to_string(),
        created_ms: 1_000,
        dismissed: false,
        seen: Default::default(),
        last_surfaced_ms: 0,
    };
    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");

    // The dispatcher applies asynchronously — poll B's canonical store.
    let store_b = NotifStore::open().expect("B notif store");
    let mut found = false;
    for _ in 0..400 {
        let rows = store_b.list("d91-notif").expect("list");
        if rows.iter().any(|r| r.notif_id == "aabb:42" && r.body == "dispatched hello") {
            found = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(found, "the dispatcher applied the feed into B's canonical store");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-NET-1] a WAN message feed lands in B's perch spool end to end —
// and the direction fact is recorded right: A's table shows its opened
// stream as locally-initiated; B's shows the same stream as peer-initiated
// (what the dispatcher claimed it by).
#[test]
fn dispatcher_funnels_wan_messages_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // An offline perch at B so the funnel takes the atomic spool path.
    let target = "ling-d91-wan";
    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(target, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open");
    let msg = WanMessage {
        target: target.to_string(),
        from: "doyle".to_string(),
        body: "undriven hello".to_string(),
        op_id: "d91-wan-1".to_string(),
    };
    a.net_stream_send(opened.stream_id, &spt_net::net::ndjson::encode_line(&msg), None, true)
        .expect("send msg");

    // Direction fact, both sides of the same stream.
    let a_rows = a.net_streams().expect("A streams").streams;
    assert!(
        a_rows.iter().any(|s| s.stream_id == opened.stream_id && s.initiated_locally),
        "A's own open is locally-initiated in A's table"
    );
    let b_rows = b_probe.net_streams().expect("B streams").streams;
    assert!(
        b_rows.iter().all(|s| !s.initiated_locally),
        "every row at B is peer-initiated (B opened nothing)"
    );

    let mut spooled = false;
    for _ in 0..400 {
        let pending = spool::peek_all_at(&perch_path).expect("peek");
        if pending.iter().any(|(_, _, body, _)| body == "undriven hello") {
            spooled = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(spooled, "the dispatcher funneled the WAN message into the spool");
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-INST-8] [int->REQ-REACH-1] remote-drive is served end to end by
// the dispatcher: B hosts a LABELED session; A attaches by session id, types,
// and reads the echo — the gate subject came from the broker's table (the
// spawn label), not wire bytes. An UNLABELED session refuses, fail-closed.
#[test]
fn dispatcher_serves_a_remote_drive_attach_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // B hosts one labeled echo session and one unlabeled one.
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    let mut b_host = connect_retry(&b_name);
    let labeled = b_host
        .spawn_session(spt_daemon::msg::SpawnReq {
            program: program.clone(),
            args: args.clone(),
            rows: 24,
            cols: 80,
            endpoint: "ling-drive".to_string(),
        })
        .expect("spawn labeled");
    let unlabeled = b_host
        .spawn_session(spt_daemon::msg::SpawnReq {
            program,
            args,
            rows: 24,
            cols: 80,
            endpoint: String::new(),
        })
        .expect("spawn unlabeled");

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");

    // Unlabeled session: the dispatcher refuses (our read side finishes
    // without a single Output record).
    let refused =
        spt_daemon::attach::request_attach(&mut a, conn.conn_id, unlabeled, 0, op())
            .expect("open refused viewport");
    a.net_stream_subscribe(refused, 0).expect("subscribe");
    loop {
        match a.read_event().expect("event") {
            spt_daemon::brain::BrokerEvent::NetStreamEof { stream_id, .. }
                if stream_id == refused =>
            {
                break; // refused: clean finish, no output ever
            }
            spt_daemon::brain::BrokerEvent::NetStreamData { stream_id, bytes, .. }
                if stream_id == refused && !bytes.is_empty() =>
            {
                panic!("an unlabeled session must never serve output");
            }
            _ => {}
        }
    }

    // Labeled session: attach, type, read the echo back — all undriven.
    let viewport =
        spt_daemon::attach::request_attach(&mut a, conn.conn_id, labeled, 0, op())
            .expect("open viewport");
    a.net_stream_subscribe(viewport, 0).expect("subscribe");
    spt_daemon::attach::send_attach_input(&mut a, viewport, b"dispatch-driven\r\n", op())
        .expect("type");
    let mut echoed = Vec::new();
    let deadline = std::time::Instant::now() + Duration::from_secs(10);
    let mut decoder = spt_net::net::attach::AttachDecoder::new();
    while std::time::Instant::now() < deadline {
        match a.read_event().expect("event") {
            spt_daemon::brain::BrokerEvent::NetStreamData { stream_id, bytes, .. }
                if stream_id == viewport =>
            {
                for rec in decoder.push(&bytes) {
                    if let spt_net::net::attach::AttachRecord::Output { data_b64, .. } = rec {
                        echoed.extend(
                            spt_daemon::msg::decode_bytes(&data_b64).expect("b64"),
                        );
                    }
                }
                if String::from_utf8_lossy(&echoed).contains("dispatch-driven") {
                    break;
                }
            }
            _ => {}
        }
    }
    assert!(
        String::from_utf8_lossy(&echoed).contains("dispatch-driven"),
        "the dispatcher served the viewport undriven; got {:?}",
        String::from_utf8_lossy(&echoed)
    );
    stop.store(true, Ordering::Relaxed);
}

// [int->REQ-REACH-1] a file fetch is served end to end by the dispatcher:
// the wire names the gating endpoint; the file pumps back and commits.
#[test]
fn dispatcher_serves_a_file_fetch_undriven() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    // The served file lives under B's canonical transfer root.
    let root = spt_store::xfer::transfers_dir();
    std::fs::create_dir_all(root.join("notes")).unwrap();
    std::fs::write(root.join("notes").join("d915.md"), b"fetched through dispatch").unwrap();

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");

    let dest = dir.path().join("pulled.md");
    let n = spt_daemon::xfer::fetch_file(
        &mut a,
        conn.conn_id,
        "ling-files",
        "notes/d915.md",
        &dest,
        "d915-x1",
        op(),
        &dir.path().join("progress-a"),
    )
    .expect("fetch");
    assert_eq!(n, 24);
    assert_eq!(std::fs::read(&dest).unwrap(), b"fetched through dispatch");
    stop.store(true, Ordering::Relaxed);
}

// [unit->REQ-NET-1] an unclassifiable stream is dropped without wedging the
// dispatcher: garbage first, then a valid notif feed still converges.
#[test]
fn dispatcher_survives_an_unknown_stream() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a = net_broker(&a_name, dir.path());
    let _b = net_broker(&b_name, dir.path());
    let stop = spawn_dispatcher_for(&b_name, &dir.path().join("scratch-b"));

    let mut a = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    seed_gates("d91-unk", &node_hex(&mut a), &[]);

    let addr = b_probe.net_status().expect("status").addr;
    let conn = a.net_dial(addr, Some(op())).expect("dial");

    // Garbage stream: not JSON at all.
    let junk = a.net_open_stream(conn.conn_id, Some(op())).expect("open junk");
    a.net_stream_send(junk.stream_id, b"this is not a protocol\n", None, true).expect("junk");

    // A valid feed afterward still lands — the loop did not die on the junk.
    let row = NotifRow {
        notif_id: "ccdd:7".to_string(),
        subnet: "d91-unk".to_string(),
        kind: "agent".to_string(),
        from_id: "oak@ccdd".to_string(),
        body: "after the junk".to_string(),
        created_ms: 1_000,
        dismissed: false,
        seen: Default::default(),
        last_surfaced_ms: 0,
    };
    let line = spt_net::net::ndjson::encode_line(&NotifRecord::Row { row });
    let opened = a.net_open_stream(conn.conn_id, Some(op())).expect("open feed");
    a.net_stream_send(opened.stream_id, &line, None, true).expect("send feed");

    let store_b = NotifStore::open().expect("B notif store");
    let mut found = false;
    for _ in 0..400 {
        if store_b.list("d91-unk").expect("list").iter().any(|r| r.notif_id == "ccdd:7") {
            found = true;
            break;
        }
        thread::sleep(Duration::from_millis(25));
    }
    assert!(found, "the dispatcher survived the unknown stream and served the next one");
    stop.store(true, Ordering::Relaxed);
}


Grep

pub enum BindScope|pub struct NetStatus|pub fn net_dial|pub fn net_status|node_id_hex|pub addr


crates\spt\src\wansend.rs-257-        info::write_info(&perch_path, &rec).unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-258-
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-259-        let mut a = connect_retry(&a_name);
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-260-        let mut b_probe = connect_retry(&b_name);
crates\spt\src\wansend.rs:261:        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
crates\spt\src\wansend.rs:262:        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-263-        let b_addr = b_probe.net_status().expect("status").addr;
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-264-
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-265-        // Ambiguity refuses before any wire work (two subnets hold the id).
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-266-        let mut ambiguous = reg_with("s1", target, &b_hex);
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-188-    let a_addr = target.net_status().expect("a status").addr;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-189-
crates\spt-daemon\tests\attach.rs-190-    \ Operator B: dial A, open the attach stream, request the session.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-191-    let mut operator = connect_retry(&name_b);
crates\spt-daemon\tests\attach.rs:192:    let b_node = operator.net_status().expect("b status").node_id_hex.expect("node id");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-193-    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-194-    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-195-    operator.net_stream_subscribe(stream_b, 0).expect("operator subscribe");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-196-
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-359-    let sid = target.spawn_session(echo_spawn_req()).expect("spawn echo child");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-360-    let a_addr = target.net_status().expect("a status").addr;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-361-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-362-    let mut operator = connect_retry(&name_b);
crates\spt-daemon\tests\attach.rs:363:    let b_node = operator.net_status().expect("b status").node_id_hex.expect("node id");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-364-    let conn = operator.net_dial(a_addr, Some(1)).expect("dial");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-365-    let stream_b = request_attach(&mut operator, conn.conn_id, sid, 0, 2).expect("request");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-366-    operator.net_stream_subscribe(stream_b, 0).expect("operator subscribe");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-367-
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\roster.rs-14-/// A snapshot of one perch for `spt list`.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\roster.rs-15-pub struct PerchEntry {
crates\spt\src\roster.rs-16-    pub id: String,
crates\spt\src\roster.rs-17-    pub state: String,
crates\spt\src\roster.rs:18:    pub address: Option<String>,
crates\spt\src\roster.rs-19-    pub ready: bool,
crates\spt\src\roster.rs-20-    pub alive: bool,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\roster.rs-21-}
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\roster.rs-22-
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-151-    store.save().expect("save access store");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-152-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-153-    // Node A dials B and opens the message stream.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-154-    let mut sender = connect_retry(&name_a);
crates\spt-daemon\tests\access.rs:155:    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-156-    let mut receiver = connect_retry(&name_b);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-157-    let addr = receiver.net_status().expect("b status").addr;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-158-    let conn = sender.net_dial(addr, Some(1)).expect("dial");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\access.rs-159-    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-129-    vis.save().expect("save visibility");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-130-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-131-
crates\spt-daemon\tests\dispatch.rs-132-fn node_hex(brain: &mut Brain) -> String {
crates\spt-daemon\tests\dispatch.rs:133:    brain.net_status().expect("status").node_id_hex.expect("net enabled")
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-134-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-135-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-136-/// Marker-stamped context body (the live write path stamps these in
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\dispatch.rs-137-/// production; the vector makes the merge driver's dominance decidable).
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-81-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-82-    let mut brain = connect_retry(&name);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-83-    let status = brain.net_status().expect("net-status");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-84-    assert!(status.enabled, "broker hosts an endpoint");
crates\spt-daemon\tests\netbroker.rs:85:    assert_eq!(status.node_id_hex.as_deref(), Some(expect_hex.as_str()));
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-86-    assert!(!status.addr.is_null(), "dialable address rides the reply");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-87-    assert_eq!(status.conn_count, 0);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-88-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-89-
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-207-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-208-    let mut brain = connect_retry(&name);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-209-    let status = brain.net_status().expect("net-status");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-210-    assert!(!status.enabled, "no endpoint hosted");
crates\spt-daemon\tests\netbroker.rs:211:    assert!(status.node_id_hex.is_none());
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-212-    assert_eq!(status.conn_count, 0);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-213-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\netbroker.rs-214-    let err = brain
crates\spt-daemon\tests\netbroker.rs-215-        .net_dial(serde_json::json!({}), None)
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-114-    let _b_broker = net_broker(&b_name, dir.path());
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-115-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-116-    let mut a_probe = connect_retry(&a_name);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-117-    let mut b_probe = connect_retry(&b_name);
crates\spt-daemon\tests\peerloop.rs:118:    let a_hex = a_probe.net_status().expect("status").node_id_hex.expect("net");
crates\spt-daemon\tests\peerloop.rs:119:    let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-120-    let b_addr = b_probe.net_status().expect("status").addr;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-121-
crates\spt-daemon\tests\peerloop.rs-122-    \ ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
crates\spt-daemon\tests\peerloop.rs-123-    let mut subnets = SubnetStore::load();
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-89-    life1.net_presence_subscribe(0).expect("subscribe");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-90-
crates\spt-daemon\tests\presence.rs-91-    \ The remote peer: a standalone broker-shaped net host that dials in.
crates\spt-daemon\tests\presence.rs-92-    let peer = NetHost::start(hermetic()).expect("peer host");
crates\spt-daemon\tests\presence.rs:93:    let peer_hex = peer.node_id_hex();
crates\spt-daemon\tests\presence.rs-94-    let addr: EndpointAddr =
crates\spt-daemon\tests\presence.rs-95-        serde_json::from_value(life1.net_status().expect("status").addr).expect("addr decodes");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-96-    peer.dial(addr).expect("peer dials the broker");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-97-
--
crates\spt-daemon\tests\presence.rs-143-        serde_json::from_value(probe.net_status().expect("status").addr).expect("addr decodes");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-144-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-145-    // The peer's whole lifecycle happens with no presence subscriber attached.
crates\spt-daemon\tests\presence.rs-146-    let peer = NetHost::start(hermetic()).expect("peer host");
crates\spt-daemon\tests\presence.rs:147:    let peer_hex = peer.node_id_hex();
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-148-    peer.dial(addr).expect("peer dials");
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-149-    // Wait until the broker registered the row before closing it.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-150-    for _ in 0..400 {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\presence.rs-151-        if probe.net_status().expect("status").conn_count == 1 {
--
crates\spt-daemon\tests\propagate.rs-147-        Node { name, cache: ReleaseCache::open(&dir.join(tag).join("releases")) }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\propagate.rs-148-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\propagate.rs-149-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\propagate.rs-150-    fn node_id(&self) -> String {
crates\spt-daemon\tests\propagate.rs:151:        connect_retry(&self.name).net_status().unwrap().node_id_hex.unwrap()
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\propagate.rs-152-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\propagate.rs-153-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\propagate.rs-154-

[Showing results with pagination = limit: 120]


1	//! The Iroh endpoint — the node's WAN/LAN presence (D1).
2	//!
3	//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
4	//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
5	//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
6	//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
7	//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
8	//!
9	//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
10	//! n0's public relays by default for zero-config onboarding, a self-host escape
11	//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
12	//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
13	//!
14	//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
15	//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
16	//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
17	//! escape hatch the daemon uses to take ownership.
18
19	use iroh::address_lookup::MdnsAddressLookup;
20	use iroh::endpoint::{presets, Incoming, PortmapperConfig};
21	use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
22	use spt_proto::identity::{Identity, PublicKey};
23
24	// Re-exported for the daemon (D4): the broker holds live QUIC connections +
25	// streams in its tables and dialable addresses in its IPC frames without growing
26	// its own iroh dependency edge — the exact version pin (0.98.2) stays in this
27	// one crate.
28	pub use iroh::endpoint::{Connection, RecvStream, SendStream};
29	pub use iroh::EndpointAddr;
30
31	/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
32	/// future wire break is a new protocol id, not a silent incompatibility.
33	pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";
34
35	/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
36	/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
37	/// path; the dependency is operational + metadata-only (traffic is end-to-end
38	/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
39	// [impl->REQ-NET-2]
40	pub const RELAY_DISCLOSURE: &str = "\
41	By default spt connects through n0's free public relay servers to find your other \
42	machines and to assist NAT hole-punching. Relays only help peers locate and connect \
43	to each other — your traffic between machines is end-to-end encrypted and the relay \
44	cannot read it. You can run your own relay instead (self-host) or disable relays \
45	entirely for a LAN-only/air-gapped setup.";
46
47	/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
48	/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
49	// [impl->REQ-NET-2]
50	#[derive(Debug, Clone)]
51	pub enum RelayPolicy {
52	    /// n0's public production relays — the zero-config "just works" default.
53	    N0Default,
54	    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
55	    SelfHosted(RelayMap),
56	    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
57	    /// hermetic loopback tests (no network reached at all).
58	    Disabled,
59	}
60
61	/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
62	/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
63	/// alone on the local network, so two machines on the same LAN find each other with
64	/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
65	// [impl->REQ-NET-1]
66	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67	pub enum LocalDiscovery {
68	    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
69	    Mdns,
70	    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
71	    Off,
72	}
73
74	/// Which interfaces the endpoint's UDP sockets bind — a mechanical knob, not
75	/// policy. [`BindScope::Loopback`] exists for hermetic tests: a loopback-only
76	/// listener never trips the Windows Defender Firewall "allow access?" prompt
77	/// (the prompt fires per freshly-built test executable that listens on
78	/// `0.0.0.0`), and it makes "no network reached" literally true at the socket.
79	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80	pub enum BindScope {
81	    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
82	    /// The production scope; required for any off-host connectivity.
83	    All,
84	    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
85	    /// nothing can reach (or prompt about) a real interface.
86	    Loopback,
87	}
88
89	/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
90	/// so iroh's churning error types don't leak into this crate's public surface
91	/// (matches the workspace's hand-rolled, dependency-free error convention).
92	#[derive(Debug)]
93	pub enum NetError {
94	    /// The endpoint could not bind its UDP socket / initialise the transport.
95	    Bind(String),
96	    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
97	    Connect(String),
98	}
99
100	impl std::fmt::Display for NetError {
101	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102	        match self {
103	            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
104	            NetError::Connect(e) => write!(f, "connect failed: {e}"),
105	        }
106	    }
107	}
108
109	impl std::error::Error for NetError {}
110
111	/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
112	pub struct NetEndpoint {
113	    inner: Endpoint,
114	}
115
116	impl NetEndpoint {
117	    /// Bind an endpoint to this node's identity under the given relay policy.
118	    ///
119	    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
120	    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
121	    /// the name are the same value.
122	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
123	    pub async fn bind(
124	        identity: &Identity,
125	        relay: RelayPolicy,
126	        discovery: LocalDiscovery,
127	    ) -> Result<NetEndpoint, NetError> {
128	        Self::bind_with_alpns(identity, relay, discovery, vec![SPT_NET_ALPN.to_vec()]).await
129	    }
130
131	    /// Bind on an explicit ALPN set instead of the default trusted [`SPT_NET_ALPN`].
132	    ///
133	    /// The pairing driver uses this to stand up a **dedicated pre-trust endpoint** bound
134	    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
135	    /// accept loop must not share the trusted message surface (ADR-0005; `pairing.rs`
136	    /// "Why a dedicated pre-trust ALPN"). All relay/discovery handling is identical to
137	    /// [`bind`](Self::bind).
138	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
139	    pub async fn bind_with_alpns(
140	        identity: &Identity,
141	        relay: RelayPolicy,
142	        discovery: LocalDiscovery,
143	        alpns: Vec<Vec<u8>>,
144	    ) -> Result<NetEndpoint, NetError> {
145	        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
146	    }
147
148	    /// The full bind form: [`bind_with_alpns`](Self::bind_with_alpns) plus an
149	    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
150	    /// endpoint never listens on a real interface (and never triggers a Windows
151	    /// firewall prompt per test binary); everything else delegates here with
152	    /// [`BindScope::All`].
153	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
154	    pub async fn bind_scoped(
155	        identity: &Identity,
156	        relay: RelayPolicy,
157	        discovery: LocalDiscovery,
158	        alpns: Vec<Vec<u8>>,
159	        scope: BindScope,
160	    ) -> Result<NetEndpoint, NetError> {
161	        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
162	        // seed, so this preserves the node's stable public-key name on the wire.
163	        let secret = SecretKey::from_bytes(&identity.seed());
164	        let builder = Endpoint::builder(presets::N0)
165	            .secret_key(secret)
166	            .alpns(alpns);
167	        // Loopback scope replaces the builder's pre-configured unspecified binds
168	        // (0.0.0.0 / [::]) with loopback-only sockets, AND disables iroh's
169	        // portmapper (UPnP/PCP/NAT-PMP — on by default), which would otherwise
170	        // bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless
171	        // on loopback, and the other source of per-test-binary Windows firewall
172	        // prompts.
173	        let builder = match scope {
174	            BindScope::All => builder,
175	            BindScope::Loopback => builder
176	                .clear_ip_transports()
177	                .bind_addr("127.0.0.1:0")
178	                .map_err(|e| NetError::Bind(e.to_string()))?
179	                .bind_addr("[::1]:0")
180	                .map_err(|e| NetError::Bind(e.to_string()))?
181	                .portmapper_config(PortmapperConfig::Disabled),
182	        };
183	        // The N0 preset already wires the n0 relay default; only override it when the
184	        // policy asks for self-host or disabled.
185	        let builder = match relay {
186	            RelayPolicy::N0Default => builder,
187	            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
188	            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
189	        };
190	        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
191	        // lookup, giving LAN-local advertise + resolve-by-id.
192	        let builder = match discovery {
193	            LocalDiscovery::Mdns => {
194	                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
195	            }
196	            LocalDiscovery::Off => builder,
197	        };
198	        let inner = builder
199	            .bind()
200	            .await
201	            .map_err(|e| NetError::Bind(e.to_string()))?;
202	        Ok(NetEndpoint { inner })
203	    }
204
205	    /// This node's public key — its stable subnet name, derived from the live
206	    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
207	    // [impl->REQ-NET-1]
208	    pub fn node_id(&self) -> PublicKey {
209	        PublicKey::from_bytes(self.inner.id().as_bytes())
210	            .expect("an iroh EndpointId is always a valid ed25519 public key")
211	    }
212
213	    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
214	    pub fn node_id_hex(&self) -> String {
215	        self.node_id().to_hex()
216	    }
217
218	    /// This endpoint's dialable address (id + currently-known direct/relay paths).
219	    /// Serialise it (serde) to hand a peer a connect ticket.
220	    pub fn addr(&self) -> EndpointAddr {
221	        self.inner.addr()
222	    }
223
224	    /// Wait until the endpoint has registered with its relays — needed before the
225	    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
226	    /// direct/loopback dial where the peer already has a direct address.
227	    pub async fn online(&self) {
228	        self.inner.online().await;
229	    }
230
231	    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
232	    /// `None` once the endpoint is closed.
233	    pub async fn accept(&self) -> Option<Incoming> {
234	        self.inner.accept().await
235	    }
236
237	    /// Gracefully close the endpoint: every held connection sends its QUIC
238	    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
239	    /// tear the runtime down before the close ever flushes, leaving peers to
240	    /// an idle timeout). The D4c presence seam relies on this for prompt
241	    /// disconnected events.
242	    // [impl->REQ-NET-1]
243	    pub async fn close(&self) {
244	        self.inner.close().await;
245	    }
246
247	    /// Dial a peer by address over [`SPT_NET_ALPN`].
248	    // [impl->REQ-NET-1]
249	    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
250	        self.connect_with_alpn(addr, SPT_NET_ALPN).await
251	    }
252
253	    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
254	    // [impl->REQ-NET-1]
255	    pub async fn connect_with_alpn(
256	        &self,
257	        addr: EndpointAddr,
258	        alpn: &[u8],
259	    ) -> Result<Connection, NetError> {
260	        self.inner
261	            .connect(addr, alpn)
262	            .await
263	            .map_err(|e| NetError::Connect(e.to_string()))
264	    }
265
266	    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
267	    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
268	    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
269	    /// knowing only each other's stable name.
270	    // [impl->REQ-NET-1]
271	    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
272	        let id = EndpointId::from_bytes(&peer.to_bytes())
273	            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
274	        self.connect(EndpointAddr::new(id)).await
275	    }
276
277	    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
278	    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
279	    pub fn iroh(&self) -> &Endpoint {
280	        &self.inner
281	    }
282	}
283
284	/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
285	/// pubkey (the trust store's identity column), letting discovery (mDNS on the
286	/// LAN, n0 DNS on the WAN) resolve the transport address. The peer pump's
287	/// production resolver (D9-1): dial trusted peers knowing only their stable
288	/// name. `None` on a malformed hex — a corrupt trust row must not panic the
289	/// pump.
290	// [impl->REQ-NET-1]
291	pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
292	    let pk = spt_proto::identity::PublicKey::from_hex(hex).ok()?;
293	    let id = EndpointId::from_bytes(&pk.to_bytes()).ok()?;
294	    Some(EndpointAddr::new(id))
295	}
296
297	#[cfg(test)]
298	mod tests {
299	    use super::*;
300
301	    /// Bind a fully hermetic endpoint: relays disabled, no discovery, loopback
302	    /// sockets only (no real interface touched, no Windows firewall prompt).
303	    async fn bind_hermetic(id: &Identity) -> NetEndpoint {
304	        NetEndpoint::bind_scoped(
305	            id,
306	            RelayPolicy::Disabled,
307	            LocalDiscovery::Off,
308	            vec![SPT_NET_ALPN.to_vec()],
309	            BindScope::Loopback,
310	        )
311	        .await
312	        .expect("hermetic bind")
313	    }
314
315	    /// Accept exactly one connection on `server` and echo a single bidi request as
316	    /// `echo:<req>`. Shared by the loopback + mDNS tests.
317	    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
318	        tokio::spawn(async move {
319	            let incoming = server.accept().await.expect("incoming");
320	            let conn = incoming
321	                .accept()
322	                .expect("accept")
323	                .await
324	                .expect("connecting");
325	            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
326	            let req = recv.read_to_end(1024).await.expect("read req");
327	            let mut echo = b"echo:".to_vec();
328	            echo.extend_from_slice(&req);
329	            send.write_all(&echo).await.expect("write echo");
330	            send.finish().expect("finish");
331	            conn.closed().await;
332	        })
333	    }
334
335	    /// Drive one ping→`echo:ping` round-trip over an established connection.
336	    async fn ping_echo(conn: &Connection) {
337	        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
338	        send.write_all(b"ping").await.expect("write ping");
339	        send.finish().expect("finish");
340	        let resp = recv.read_to_end(1024).await.expect("read resp");
341	        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
342	    }
343
344	    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
345	    // iroh EndpointId equals the node's public key (same seed → same keypair).
346	    #[tokio::test]
347	    async fn endpoint_id_binds_to_identity() {
348	        let id = Identity::generate();
349	        let ep = bind_hermetic(&id).await;
350	        assert_eq!(
351	            ep.node_id(),
352	            id.public_key(),
353	            "endpoint id must equal node pubkey"
354	        );
355	        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
356	    }
357
358	    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
359	    // QUIC with relays DISABLED, no discovery, and loopback-only sockets — fully
360	    // hermetic (no network), proving the ALPN + connect/accept path by direct
361	    // address. (The cross-node WAN `int` stage is the two-host E2E at D9; this is
362	    // the component-level evidence.)
363	    #[tokio::test]
364	    async fn two_endpoints_loopback_echo() {
365	        let server = bind_hermetic(&Identity::generate()).await;
366	        let server_addr = server.addr();
367	        let srv = spawn_echo_once(server);
368
369	        let client = bind_hermetic(&Identity::generate()).await;
370	        let conn = client.connect(server_addr).await.expect("connect");
371	        ping_echo(&conn).await;
372	        conn.close(0u32.into(), b"done");
373
374	        srv.await.expect("server task joins");
375	    }
376
377	    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
378	    // knowing only the peer's node id (no address ticket) — the address is resolved
379	    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
380	    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
381	    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
382	    #[tokio::test]
383	    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
384	    async fn mdns_discovery_connects_by_id() {
385	        let server = NetEndpoint::bind(
386	            &Identity::generate(),
387	            RelayPolicy::Disabled,
388	            LocalDiscovery::Mdns,
389	        )
390	        .await
391	        .expect("server bind");
392	        let server_pk = server.node_id();
393	        let srv = spawn_echo_once(server);
394
395	        let client = NetEndpoint::bind(
396	            &Identity::generate(),
397	            RelayPolicy::Disabled,
398	            LocalDiscovery::Mdns,
399	        )
400	        .await
401	        .expect("client bind");
402	        // Give mDNS a moment to advertise + resolve before dialing by id alone.
403	        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
404	        let conn = client
405	            .connect_by_id(server_pk)
406	            .await
407	            .expect("connect by id resolves over mDNS");
408	        ping_echo(&conn).await;
409	        conn.close(0u32.into(), b"done");
410
411	        srv.await.expect("server task joins");
412	    }
413	}
414

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, Incoming, PortmapperConfig};
use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

// Re-exported for the daemon (D4): the broker holds live QUIC connections +
// streams in its tables and dialable addresses in its IPC frames without growing
// its own iroh dependency edge — the exact version pin (0.98.2) stays in this
// one crate.
pub use iroh::endpoint::{Connection, RecvStream, SendStream};
pub use iroh::EndpointAddr;

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
/// alone on the local network, so two machines on the same LAN find each other with
/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
// [impl->REQ-NET-1]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalDiscovery {
    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
    Mdns,
    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
    Off,
}

/// Which interfaces the endpoint's UDP sockets bind — a mechanical knob, not
/// policy. [`BindScope::Loopback`] exists for hermetic tests: a loopback-only
/// listener never trips the Windows Defender Firewall "allow access?" prompt
/// (the prompt fires per freshly-built test executable that listens on
/// `0.0.0.0`), and it makes "no network reached" literally true at the socket.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindScope {
    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
    /// The production scope; required for any off-host connectivity.
    All,
    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
    /// nothing can reach (or prompt about) a real interface.
    Loopback,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

impl std::error::Error for NetError {}

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
    ) -> Result<NetEndpoint, NetError> {
        Self::bind_with_alpns(identity, relay, discovery, vec![SPT_NET_ALPN.to_vec()]).await
    }

    /// Bind on an explicit ALPN set instead of the default trusted [`SPT_NET_ALPN`].
    ///
    /// The pairing driver uses this to stand up a **dedicated pre-trust endpoint** bound
    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
    /// accept loop must not share the trusted message surface (ADR-0005; `pairing.rs`
    /// "Why a dedicated pre-trust ALPN"). All relay/discovery handling is identical to
    /// [`bind`](Self::bind).
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind_with_alpns(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
        alpns: Vec<Vec<u8>>,
    ) -> Result<NetEndpoint, NetError> {
        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
    }

    /// The full bind form: [`bind_with_alpns`](Self::bind_with_alpns) plus an
    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
    /// endpoint never listens on a real interface (and never triggers a Windows
    /// firewall prompt per test binary); everything else delegates here with
    /// [`BindScope::All`].
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind_scoped(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
        alpns: Vec<Vec<u8>>,
        scope: BindScope,
    ) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(alpns);
        // Loopback scope replaces the builder's pre-configured unspecified binds
        // (0.0.0.0 / [::]) with loopback-only sockets, AND disables iroh's
        // portmapper (UPnP/PCP/NAT-PMP — on by default), which would otherwise
        // bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless
        // on loopback, and the other source of per-test-binary Windows firewall
        // prompts.
        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
        };
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
        // lookup, giving LAN-local advertise + resolve-by-id.
        let builder = match discovery {
            LocalDiscovery::Mdns => {
                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
            }
            LocalDiscovery::Off => builder,
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Gracefully close the endpoint: every held connection sends its QUIC
    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
    /// tear the runtime down before the close ever flushes, leaving peers to
    /// an idle timeout). The D4c presence seam relies on this for prompt
    /// disconnected events.
    // [impl->REQ-NET-1]
    pub async fn close(&self) {
        self.inner.close().await;
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.connect_with_alpn(addr, SPT_NET_ALPN).await
    }

    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
    // [impl->REQ-NET-1]
    pub async fn connect_with_alpn(
        &self,
        addr: EndpointAddr,
        alpn: &[u8],
    ) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, alpn)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
    /// knowing only each other's stable name.
    // [impl->REQ-NET-1]
    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
        let id = EndpointId::from_bytes(&peer.to_bytes())
            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
        self.connect(EndpointAddr::new(id)).await
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
/// pubkey (the trust store's identity column), letting discovery (mDNS on the
/// LAN, n0 DNS on the WAN) resolve the transport address. The peer pump's
/// production resolver (D9-1): dial trusted peers knowing only their stable
/// name. `None` on a malformed hex — a corrupt trust row must not panic the
/// pump.
// [impl->REQ-NET-1]
pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
    let pk = spt_proto::identity::PublicKey::from_hex(hex).ok()?;
    let id = EndpointId::from_bytes(&pk.to_bytes()).ok()?;
    Some(EndpointAddr::new(id))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Bind a fully hermetic endpoint: relays disabled, no discovery, loopback
    /// sockets only (no real interface touched, no Windows firewall prompt).
    async fn bind_hermetic(id: &Identity) -> NetEndpoint {
        NetEndpoint::bind_scoped(
            id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_NET_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("hermetic bind")
    }

    /// Accept exactly one connection on `server` and echo a single bidi request as
    /// `echo:<req>`. Shared by the loopback + mDNS tests.
    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
        tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        })
    }

    /// Drive one ping→`echo:ping` round-trip over an established connection.
    async fn ping_echo(conn: &Connection) {
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
    }

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = bind_hermetic(&id).await;
        assert_eq!(
            ep.node_id(),
            id.public_key(),
            "endpoint id must equal node pubkey"
        );
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED, no discovery, and loopback-only sockets — fully
    // hermetic (no network), proving the ALPN + connect/accept path by direct
    // address. (The cross-node WAN `int` stage is the two-host E2E at D9; this is
    // the component-level evidence.)
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = bind_hermetic(&Identity::generate()).await;
        let server_addr = server.addr();
        let srv = spawn_echo_once(server);

        let client = bind_hermetic(&Identity::generate()).await;
        let conn = client.connect(server_addr).await.expect("connect");
        ping_echo(&conn).await;
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }

    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
    // knowing only the peer's node id (no address ticket) — the address is resolved
    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
    #[tokio::test]
    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
    async fn mdns_discovery_connects_by_id() {
        let server = NetEndpoint::bind(
            &Identity::generate(),
            RelayPolicy::Disabled,
            LocalDiscovery::Mdns,
        )
        .await
        .expect("server bind");
        let server_pk = server.node_id();
        let srv = spawn_echo_once(server);

        let client = NetEndpoint::bind(
            &Identity::generate(),
            RelayPolicy::Disabled,
            LocalDiscovery::Mdns,
        )
        .await
        .expect("client bind");
        // Give mDNS a moment to advertise + resolve before dialing by id alone.
        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        let conn = client
            .connect_by_id(server_pk)
            .await
            .expect("connect by id resolves over mDNS");
        ping_echo(&conn).await;
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}


Bash

ls crates/spt-net/src/net/pairing/ 2>/dev/null; grep -n "pub fn\|pub async fn" crates/spt-net/src/net/pairing.rs 2>/dev/null | head -40

List pairing module functions


ratelimit.rs
rendezvous.rs
spake.rs
totp.rs
transcript.rs
wire.rs


132:pub async fn run_responder
307:pub async fn run_initiator


Found 1 file
crates\spt-daemon\src\registryhost.rs