feat(tickets): member-issued tickets so rooms outlive their creator (W7 P3)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 04:20:50 -04:00
co-authored by Claude Opus 4.8
parent a7a4da3cd4
commit c90dcf71ef
2 changed files with 57 additions and 1 deletions
+11 -1
View File
@@ -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);
}
+46
View File
@@ -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::<PeerSpeakTicket>() {
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::<PeerSpeakTicket>().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();