Files
peerspeak/src/presence.rs
T
molluskandClaude Opus 4.8 4227ecc61d feat(w7): cosmetic room labels carried in the ticket (P5)
Rooms can now be named. A "Room name (optional)" field on the home Create card
mints a ticket carrying the label; PeerSpeakTicket gains a #[serde(default)]
`name` field (backward/forward compatible — serde ignores unknown fields and
defaults missing ones, so old/new builds still interoperate, just without
labels). restamp preserves the label so member-issued doors keep it; new
label_of helper reads it. Every member (creator or joiner) sets current_room.name
from the ticket, so presence reports a consistent "in <name>" to friends, and the
room-screen header shows the label under the wordmark. Labels are sanitized via
sanitize_name on both mint and display (untrusted peer-supplied ticket).

CoreCommand::Join gains room_name (used only when creating). +2 ticket tests
(label round-trip through restamp/label_of, pre-label backward-compat). clippy
--all-targets clean, 257 lib tests green.

Pure seam unit-tested + home field screenshot-verified; the in-room header label
and friend-side "in HangOut" presence display need a live/2-machine confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:32:11 -04:00

253 lines
10 KiB
Rust

//! Friends presence/control plane — pure protocol + policy (W7 P4 core).
//!
//! This is the logic half of the "friends-only idle listener" from
//! `docs/contacts-plan.md`: while the app is open it answers presence pings from
//! *friends only* (no stranger surface, no DNS beacon) and, if we're currently in
//! a gathering, hands back a one-click-join member ticket. Here we define the
//! wire messages, the authorization gate, the user's presence posture, and the
//! defensive interpretation of replies — all pure and unit-tested.
//!
//! **Deliberately NOT here (deferred to a 2-machine session):** binding the live
//! control endpoint, its accept loop, and the outbound ping scheduler. Those are
//! the I/O edges; keeping them out means the security-critical decisions (who we
//! answer, what we trust from a peer) are testable in isolation.
use crate::friends::FriendStore;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
/// The user's presence posture — how reachable they are to friends while idle.
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
/// with no presence beacon.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PresenceMode {
/// Don't answer pings at all — appear offline to everyone, even friends.
Invisible,
/// Answer pings from friends only, from our saved address. No DNS beacon.
/// The default.
#[default]
Normal,
/// Like `Normal`, and additionally publish to discovery so friends can still
/// find us after a network change (opt-in; the publish itself is wired with
/// the live endpoint later). Asymmetric: only the mover needs this on.
Discoverable,
}
impl PresenceMode {
/// All postures, default first — the option list for the Settings/home picker.
pub const ALL: [PresenceMode; 3] =
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable];
/// Whether this posture publishes to discovery (the only mode that does).
pub fn publishes_to_discovery(self) -> bool {
matches!(self, PresenceMode::Discoverable)
}
/// Whether this posture answers friend pings at all.
pub fn answers_pings(self) -> bool {
!matches!(self, PresenceMode::Invisible)
}
}
impl std::fmt::Display for PresenceMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self {
PresenceMode::Normal => "Normal — friends only",
PresenceMode::Invisible => "Invisible — appear offline",
PresenceMode::Discoverable => "Discoverable — findable after a move",
};
f.write_str(label)
}
}
/// What a peer advertises about the gathering they're currently in. Both fields
/// are peer-supplied and therefore untrusted — see [`interpret_pong`], which
/// sanitizes the name and validates the ticket before surfacing them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomPresence {
/// The cosmetic room label ("HangOut").
pub name: String,
/// A member-issued ticket the recipient can one-click join (points at the
/// sender's own address — see [`crate::network::PeerSpeakTicket::restamp`]).
pub ticket: String,
}
/// A message on the friends presence/control plane.
///
/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add
/// variants without breaking older peers (an unknown tag fails to parse and is
/// dropped, rather than being misread as another variant).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMsg {
/// "Are you there?" — a presence probe.
Ping,
/// Reply to a ping: I'm online; `room` is `Some` when I'm in a gathering you
/// could join.
Pong { room: Option<RoomPresence> },
}
/// The authorization gate for the idle listener: whether to answer an inbound
/// ping from `from`. **Friends-only, and never while invisible.** This is the
/// whitelist that keeps the always-on-while-open endpoint from being a
/// stranger-facing spam/DoS surface — the single most security-critical decision
/// in the friends-first model, so it lives in one pure, tested function.
///
/// `from` must be the connection's *authenticated* remote id (`remote_id()`),
/// never a value carried in the payload.
pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMode) -> bool {
mode.answers_pings() && friends.contains(from)
}
/// What we learned about a friend from a successful ping reply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence {
/// Online, but not in a gathering we can join.
Online,
/// Online and in a joinable gathering (name already sanitized, ticket already
/// validated as parseable).
InRoom { name: String, ticket: String },
}
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is
/// not, so it yields `None`). When the peer reports a room, we **sanitize the
/// peer-supplied name** and **only surface it as joinable if the ticket actually
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile
/// ticket downgrades the friend to plain `Online` rather than offering a dead /
/// dangerous Join button. (We still never auto-join; the user clicks.)
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> {
match msg {
ControlMsg::Ping => None,
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
ControlMsg::Pong { room: Some(r) } => {
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() {
Some(FriendPresence::InRoom {
name: crate::sanitize::sanitize_name(&r.name),
ticket: r.ticket.clone(),
})
} else {
// Online, but the advertised room is unusable — don't offer Join.
Some(FriendPresence::Online)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::PeerSpeakTicket;
use iroh::{EndpointAddr, SecretKey};
fn id() -> EndpointId {
SecretKey::generate().public()
}
fn valid_ticket(for_id: EndpointId) -> String {
PeerSpeakTicket {
host_addr: EndpointAddr::from(for_id),
topic_id: [9u8; 32],
name: String::new(),
}
.to_string()
}
#[test]
fn should_answer_is_friends_only_and_respects_mode() {
let mut friends = FriendStore::default();
let friend = id();
let stranger = id();
friends.add(friend, "Pal".into(), None);
// Friend + a ping-answering mode -> answer.
assert!(should_answer(&friend, &friends, PresenceMode::Normal));
assert!(should_answer(&friend, &friends, PresenceMode::Discoverable));
// Friend but invisible -> silent.
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
// Stranger is NEVER answered, in any mode.
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable));
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
}
#[test]
fn presence_mode_flags() {
assert!(PresenceMode::Discoverable.publishes_to_discovery());
assert!(!PresenceMode::Normal.publishes_to_discovery());
assert!(!PresenceMode::Invisible.publishes_to_discovery());
assert!(PresenceMode::Normal.answers_pings());
assert!(PresenceMode::Discoverable.answers_pings());
assert!(!PresenceMode::Invisible.answers_pings());
assert_eq!(PresenceMode::default(), PresenceMode::Normal);
}
#[test]
fn control_msg_round_trips() {
let cases = [
ControlMsg::Ping,
ControlMsg::Pong { room: None },
ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }),
},
];
for msg in cases {
let bytes = serde_json::to_vec(&msg).unwrap();
let back: ControlMsg = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back, msg);
}
}
#[test]
fn unknown_message_tag_is_rejected() {
// Forward-compat: an unknown variant fails to parse (dropped, not misread).
assert!(serde_json::from_str::<ControlMsg>(r#"{"type":"nonsense"}"#).is_err());
}
#[test]
fn interpret_ping_is_not_a_reply() {
assert_eq!(interpret_pong(&ControlMsg::Ping), None);
}
#[test]
fn interpret_pong_online_and_inroom() {
// No room -> Online.
assert_eq!(
interpret_pong(&ControlMsg::Pong { room: None }),
Some(FriendPresence::Online)
);
// Valid ticket -> InRoom with a sanitized name.
let t = valid_ticket(id());
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
});
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
}
#[test]
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
// A friend reporting a room with an unparseable ticket is treated as just
// Online — no dead/hostile Join button is surfaced.
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
});
assert_eq!(got, Some(FriendPresence::Online));
}
#[test]
fn interpret_pong_sanitizes_a_hostile_room_name() {
// Control/bidi characters in a peer-supplied name are stripped.
let t = valid_ticket(id());
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
});
match got {
Some(FriendPresence::InRoom { name, .. }) => {
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
assert!(!name.contains('\u{0007}'), "control char must be stripped");
}
other => panic!("expected InRoom, got {other:?}"),
}
}
}