From c90dcf71efa1d93d30874abceeeb81085b8e9a7c Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 15 Jun 2026 04:20:50 -0400 Subject: [PATCH] feat(tickets): member-issued tickets so rooms outlive their creator (W7 P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ticket a member sees/copies for sharing is now stamped with THEIR OWN live address + the room's topic, not the (possibly someone else's) ticket they joined with. So every member — not just the creator — hands out a working door that bootstraps newcomers off themselves; a room stays reachable as long as anyone inside can share a ticket, even after the creator leaves. Pure seam PeerSpeakTicket::restamp(ticket_str, my_addr): re-parse, swap host_addr to mine, keep topic_id; no-op for an unparseable string or when the addr is already mine. The core re-stamps only the DISPLAY copy sent in RoomJoined; the join/bootstrap ticket_str and the A8 retain logic are untouched, so this is non-breaking (same wire format, different addr). +3 unit tests (swaps addr/keeps topic, idempotent for same addr, passes through unparseable). 248 lib tests green, clippy clean, release builds. Tests-green; the end-to-end 'creator leaves, joiner's ticket still works' behaviour wants a 2-machine field test. Multi-bootstrap (Vec) + ticket encoding tightening deferred (both breaking wire changes — want daylight). Co-Authored-By: Claude Opus 4.8 --- src/core/mod.rs | 12 +++++++++++- src/network/mod.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/core/mod.rs b/src/core/mod.rs index d0ca938..f3397f3 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1078,7 +1078,17 @@ async fn run_core_loop( }; let self_id = endpoint.id().to_string(); - let _ = ui_tx.send(UiEvent::RoomJoined { ticket: ticket_str, self_id }).await; + // Member-issued ticket (W7 P3): the ticket we hand to the UI for + // sharing is stamped with OUR OWN live address + the room's topic, + // not the (possibly someone else's) ticket we joined with. So every + // member — not just the creator — hands out a working door pointing + // at themselves, which is what lets a room outlive its creator. We + // keep `ticket_str` untouched for joining + the A8 retain logic; we + // only re-stamp the *display* copy. Re-stamping is a no-op for the + // creator (same addr+topic), and a no-op if the ticket can't be + // parsed (a malformed join, which fails anyway). + let share_ticket = PeerSpeakTicket::restamp(&ticket_str, endpoint.addr()); + let _ = ui_tx.send(UiEvent::RoomJoined { ticket: share_ticket, self_id }).await; active_session = Some(session); } diff --git a/src/network/mod.rs b/src/network/mod.rs index c609c98..0178700 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -84,6 +84,20 @@ pub struct PeerSpeakTicket { pub topic_id: [u8; 32], } +impl PeerSpeakTicket { + /// Member-issued ticket (W7 P3): re-stamp an existing ticket string with our + /// OWN address while keeping its room `topic_id`, so any member can hand out a + /// working door that bootstraps off themselves — the mechanism that lets a + /// room outlive its creator. A no-op (returns the input unchanged) if the + /// string can't be parsed. Re-stamping with the same address is idempotent. + pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String { + match ticket_str.parse::() { + Ok(t) => PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id }.to_string(), + Err(_) => ticket_str.to_string(), + } + } +} + impl std::fmt::Display for PeerSpeakTicket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // serde_json on a struct of String/[u8;32] fields is infallible in practice, @@ -223,6 +237,38 @@ mod tests { assert!(matches!(res3, Err(NetError::InvalidTicket(_)))); } + #[test] + fn test_restamp_swaps_addr_keeps_topic() { + // A ticket "from" the host, then re-stamped by another member. + let host = SecretKey::generate().public(); + let member = SecretKey::generate().public(); + let topic_id = [42u8; 32]; + let original = PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id }.to_string(); + + let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member)); + let restamped = restamped_str.parse::().unwrap(); + // Same room, but the door now points at the member, not the host. + assert_eq!(restamped.topic_id, topic_id); + assert_eq!(restamped.host_addr.id, member); + assert_ne!(restamped.host_addr.id, host); + } + + #[test] + fn test_restamp_is_idempotent_for_same_addr() { + let me = SecretKey::generate().public(); + let topic_id = [7u8; 32]; + let mine = PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id }.to_string(); + // Re-stamping my own ticket with my own addr changes nothing. + assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine); + } + + #[test] + fn test_restamp_passes_through_unparseable() { + let me = SecretKey::generate().public(); + // A malformed ticket is returned unchanged (the join will fail anyway). + assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket"); + } + #[test] fn test_peer_state_serde_round_trip() { let original = sample_peer_state();