Skip to main content

huddle_core/app/
mod.rs

1pub mod events;
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use base64::engine::general_purpose::STANDARD as B64;
9use base64::Engine;
10use libp2p::{Multiaddr, PeerId};
11use tokio::sync::broadcast;
12use tracing::{debug, error, info, warn};
13
14use crate::config;
15use crate::crypto::passphrase::{self, KEY_LEN, SALT_LEN};
16use crate::crypto::RoomCrypto;
17use crate::error::{HuddleError, Result};
18use crate::files::encryption::{self as file_encryption, EncryptedFileMeta};
19use crate::files::FileManager;
20use crate::identity::Identity;
21use crate::network::events::NetworkEvent;
22use crate::network::server::{ServerClient, ServerEvent};
23use crate::network::protocol::{encode_wire, RoomAnnouncement, RoomMessage, WireMessage};
24use crate::network::transport::{self, TransportId, TransportProfile};
25use crate::network::{self, NetworkHandle, NetworkMode};
26use crate::storage::repo::{
27    self, derive_room_id, AttachmentStatus, KnownPeer, RoomKind, StoredAttachment, StoredRoom,
28    StoredRoomMember,
29};
30use crate::storage::{self, Db};
31
32pub use self::events::{AppEvent, DiscoveredRoom};
33
34/// Lobby-facing view of a known dial peer: persisted address plus
35/// runtime "is the connection currently up?" status.
36#[derive(Debug, Clone)]
37pub struct KnownPeerStatus {
38    pub address: String,
39    pub label: Option<String>,
40    pub last_connected_at: Option<i64>,
41    pub connected_peer_id: Option<PeerId>,
42    /// Ed25519 fingerprint learned from libp2p Identify. `None` until
43    /// the first successful connect completes. The TUI uses this to
44    /// resolve usernames + start DMs against the dialed peer.
45    pub fingerprint: Option<String>,
46}
47
48/// huddle 1.0: a unified, display-ready contact assembled from the durable
49/// `contacts` address book joined with live, derived state. Unlike
50/// [`KnownPeerStatus`] (one row per ephemeral libp2p multiaddr), this is
51/// keyed by the stable fingerprint, so it survives a peer leaving the LAN —
52/// the durable link that lets two people keep chatting over the relay.
53#[derive(Debug, Clone)]
54pub struct ContactView {
55    pub fingerprint: String,
56    /// User-chosen alias, if set.
57    pub alias: Option<String>,
58    /// Signed self-declared username from `peer_profiles`, if any.
59    pub username: Option<String>,
60    /// Canonical DM room id for one-step messaging.
61    pub dm_room_id: String,
62    pub verified: bool,
63    pub trusted: bool,
64    /// True when we currently have *any* live path to the peer: a libp2p
65    /// connection (LAN/direct) OR the relay is up (reachable via mailbox).
66    pub reachable: bool,
67    /// True specifically when a direct libp2p connection is live (LAN).
68    pub lan_connected: bool,
69    /// How the contact entered the book: dm / request / dial / lan / invite.
70    pub source: String,
71    pub added_at: i64,
72    pub last_seen: Option<i64>,
73}
74
75/// huddle 0.7: compute the deterministic room_id for a 1-1 DM between two
76/// fingerprints. Both peers, regardless of who calls `start_direct` first,
77/// derive identical IDs — no `created_at` mixing, no creator-fingerprint
78/// asymmetry. The pair is sorted lexicographically so the function is
79/// commutative.
80///
81/// Format: `hex(sha256("huddle-dm-v1\0" || min(a, b) || "\0" || max(a, b)))`
82/// truncated to 16 bytes (32 hex chars), matching the `derive_room_id`
83/// output length so the new DM IDs are indistinguishable from group IDs
84/// at the topic-name layer (small attacker uniformity benefit).
85pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
86    use sha2::{Digest, Sha256};
87    let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
88    let mut hasher = Sha256::new();
89    hasher.update(b"huddle-dm-v1\0");
90    hasher.update(lo.as_bytes());
91    hasher.update(b"\0");
92    hasher.update(hi.as_bytes());
93    hex::encode(&hasher.finalize()[..16])
94}
95
96/// Parse a user-entered dial address into a libp2p `Multiaddr`.
97/// Accepts `ip:port`, `[ipv6]:port`, or a raw multiaddr starting with `/`.
98pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
99    let trimmed = input.trim();
100    if trimmed.is_empty() {
101        return Err(HuddleError::Other("address is empty".into()));
102    }
103    if trimmed.starts_with('/') {
104        return trimmed
105            .parse::<Multiaddr>()
106            .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
107    }
108    if let Some(rest) = trimmed.strip_prefix('[') {
109        let (host, port) = rest
110            .split_once("]:")
111            .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
112        let port: u16 = port
113            .parse()
114            .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
115        return format!("/ip6/{}/tcp/{}", host, port)
116            .parse::<Multiaddr>()
117            .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
118    }
119    let (host, port) = trimmed
120        .rsplit_once(':')
121        .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
122    if host.contains(':') {
123        return Err(HuddleError::Other(format!(
124            "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
125        )));
126    }
127    let port: u16 = port
128        .parse()
129        .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
130    format!("/ip4/{}/tcp/{}", host, port)
131        .parse::<Multiaddr>()
132        .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
133}
134
135/// State for a room we've created or joined this session.
136struct ActiveRoom {
137    info: StoredRoom,
138    crypto: Option<RoomCrypto>,
139    /// Argon2id-derived 32-byte key for unwrapping incoming session keys.
140    /// None for unencrypted rooms.
141    passphrase_key: Option<[u8; KEY_LEN]>,
142    /// Fingerprints of members currently known to be in the room.
143    members: HashSet<String>,
144    /// Ephemeral typing indicators: fingerprint → unix expiry. Pruned
145    /// on read; never persisted.
146    typers: HashMap<String, i64>,
147    /// Phase F: we joined via a short-lived code rather than the
148    /// passphrase. We have other members' session keys (delivered via
149    /// the CodeJoinResponse ECDH handshake) so we can decrypt; but
150    /// without the passphrase we can't wrap our own outbound session
151    /// key for other members. Read-only until an owner re-onboards us
152    /// with the full passphrase. Defaults false for passphrase joins.
153    read_only: bool,
154    /// Phase F: owner-issued join codes for this room (owner side
155    /// only). Pairs of (code, expires_at_unix). Single-use; entries
156    /// removed after a successful CodeJoinResponse goes out.
157    issued_codes: Vec<(String, i64)>,
158}
159
160const TYPING_TTL_SECS: i64 = 3;
161
162/// TTL for a discovered room before it's considered stale (re-announcements
163/// happen every 15 seconds; after 45s of silence we drop it).
164const DISCOVERED_TTL_SECS: i64 = 45;
165const ANNOUNCE_INTERVAL_SECS: u64 = 15;
166
167/// Phase G: in-flight SAS verification state, keyed by tx_id. Held in
168/// memory only; survives just long enough for the two-message
169/// handshake + the user pressing Match on both sides.
170struct SasFlow {
171    room_id: String,
172    partner_fingerprint: String,
173    our_secret: x25519_dalek::StaticSecret,
174    /// Set once we know both sides' pubkeys → the derived SAS code.
175    sas_code: Option<crate::crypto::sas::SasCode>,
176    our_confirmed: bool,
177    their_confirmed: bool,
178    /// huddle 0.7.11: latch that flips true the first time `finish_sas`
179    /// runs for this flow. Prevents a race between `sas_match` and the
180    /// inbound `SasConfirm{matched:true}` handler both observing
181    /// `both_done = true` and each calling `finish_sas` — pre-0.7.11
182    /// that double-fired `SasVerified` and re-ran the DB writes.
183    finalized: bool,
184}
185
186/// huddle 0.8: the canonical centralized server, reachable only as a Tor
187/// v3 onion. Baked in so the client connects to the operator's relay by
188/// default; override with the `--server <ws-url>` CLI flag, disable with
189/// `--no-server`. Reached through the local Tor SOCKS5 proxy.
190pub const DEFAULT_SERVER_URL: &str =
191    "ws://huddleg2647kbrmngflqai23f4rrc7l5dnszz5lij76uhqzmkebx2mid.onion:80/ws";
192/// huddle 1.1: the operator's **clearnet** door onto the SAME relay backend as
193/// [`DEFAULT_SERVER_URL`], fronted by a cloudflared tunnel (valid TLS, no
194/// domain of our own). Baked in so users who can't reach Tor still connect with
195/// zero config. It sits LAST in [`default_fallback_order`], so a working onion
196/// is always preferred and a Tor user never dials clearnet — this only lights
197/// up when the onion is unreachable.
198///
199/// huddle 1.1.5: this is now a **stable** address — a free Cloudflare
200/// `*.workers.dev` Worker that WS-proxies to the operator's relay. The Worker
201/// reads the relay's current (rotating) `cloudflared` backend from KV, which the
202/// VPS keeps fresh on every rotation, so this hostname never goes stale (unlike
203/// the raw `*.trycloudflare.com` quick-tunnel URLs baked in 1.1.0–1.1.4). It
204/// exists for users in regions where Tor itself is blocked. Still LAST in
205/// [`default_fallback_order`], so a working onion is always preferred and a Tor
206/// user never dials clearnet. Override per-client with `--clearnet-server`,
207/// `clearnet_url` in config.toml, or Settings → Network; an explicit value
208/// always wins over this default.
209pub const DEFAULT_CLEARNET_URL: &str =
210    "wss://huddle-ws-proxy.richer-richard.workers.dev/ws";
211/// Local Tor SOCKS5 proxy used to dial `.onion` server URLs.
212pub const DEFAULT_TOR_SOCKS: &str = "127.0.0.1:9050";
213
214#[derive(Clone)]
215pub struct AppHandle {
216    identity: Arc<Identity>,
217    network: NetworkHandle,
218    mode: NetworkMode,
219    active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
220    discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
221    /// Encrypted rooms loaded from storage that we haven't rejoined yet
222    /// in this session (their passphrase-derived key isn't in memory).
223    /// Surfaced in the lobby so the user can re-enter with passphrase.
224    restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
225    /// Peer addresses we've dialed in this process; tracks "is the
226    /// connection currently up" for known peers shown in the lobby.
227    connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
228    /// File chunking + cache + downloads.
229    file_manager: Arc<FileManager>,
230    db: Db,
231    /// 32-byte key Megolm session pickles are encrypted under at rest —
232    /// an HKDF subkey of the master key, or all-zero on the
233    /// `--no-master-passphrase` / unencrypted-DB path.
234    session_persist_key: [u8; 32],
235    /// Phase G: active SAS verifications. Keyed by tx_id (the random
236    /// 16-byte salt picked by the initiator + base64'd).
237    sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
238    /// Phase F: ephemeral X25519 secrets the joiner is holding while
239    /// they wait for the owner's `CodeJoinResponse`. Keyed by
240    /// `(room_id, joiner_fp)` so multiple joiners in the same room can
241    /// be in flight concurrently without trampling each other; and so
242    /// the 30s timeout task (see `join_room_with_code`) can clean up
243    /// its own entry by composite key without racing with peers.
244    pending_code_secrets:
245        Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
246    /// Phase C follow-up: tracks "we dialed this multiaddr because of
247    /// an invite link claiming this fingerprint." When the peer
248    /// identifies (and we can derive their real fp), the post-dial arm
249    /// looks the multiaddr up here and compares — if the claimed and
250    /// derived fingerprints don't match, we disconnect and surface
251    /// an `InviteFingerprintMismatch` event.
252    ///
253    /// libp2p's `/p2p/<peer-id>` segment already enforces this at the
254    /// transport level when present (and our invite generator always
255    /// includes it), so this is defense in depth — but it also makes
256    /// the assert explicit so future invite-format changes can't slip
257    /// in a forgeable fingerprint label.
258    pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
259    /// Phase D follow-up: addresses confirmed reachable by AutoNAT v2
260    /// probes. We emit a `NatStatusChanged` whenever this set
261    /// transitions between empty (private / undetected) and
262    /// non-empty (reachable), so the TUI badge doesn't flap on every
263    /// individual probe.
264    nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
265    /// Phase D follow-up: `/p2p-circuit` reservation addresses we've
266    /// established via configured relays. These are populated when
267    /// `RelayReservationEstablished` arrives and feed into the
268    /// `RoomAnnouncement.host_addrs` field so cross-internet peers
269    /// can bootstrap without an invite link.
270    relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
271    /// Phase D follow-up: per-creator-fingerprint last-dial timestamp.
272    /// Throttles the opportunistic dial we issue when an announcement
273    /// arrives carrying `host_addrs` — we re-dial the same announcer
274    /// at most once per `HOST_ADDR_DIAL_BACKOFF_SECS`.
275    host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
276    /// huddle 0.5: per-peer last-broadcast timestamp (ms) for our own
277    /// `ProfileUpdate`. The `PeerIdentified` handler re-broadcasts our
278    /// current username to a newly-identified peer so they learn it
279    /// without waiting for a change, but we dedupe with a
280    /// `PROFILE_REBROADCAST_FLOOR_MS` floor so a noisy reconnect cycle
281    /// doesn't spam the gossipsub mesh.
282    last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
283    /// huddle 0.7.7: addresses the local user just initiated a dial on
284    /// (`d` / `a` / paste-invite). When `PeerIdentified` lands for one
285    /// of these, we open (or reuse) a DM with the identified peer and
286    /// emit `AutoOpenDm` so the TUI can switch into the new pane. The
287    /// set is consumed on use, so a passive auto-reconnect or an
288    /// inbound dial never triggers the auto-DM.
289    pending_auto_dm_addrs: Arc<Mutex<HashSet<String>>>,
290    app_event_tx: broadcast::Sender<AppEvent>,
291    /// huddle 0.8: whether a centralized-server URL was configured at
292    /// startup (i.e. NOT `--no-server`). Drives the TUI relay badge: with
293    /// no server configured we show nothing, rather than a permanently
294    /// "disconnected" indicator. Set once at construction, never changes.
295    server_enabled: bool,
296    /// huddle 1.0: relay room ids we subscribe to that aren't chat rooms —
297    /// currently just our own `inbox_room_id` for contact requests. Kept
298    /// separate from `active_rooms` so they don't appear in the sidebar, but
299    /// chained into the `Hello` room set so the relay re-registers the
300    /// membership on every reconnect (otherwise inbox requests are missed
301    /// after a reconnect).
302    aux_subscriptions: Arc<Mutex<HashSet<String>>>,
303    /// huddle 1.0: which transport "door" the relay connection is currently
304    /// using (set on connect, cleared on disconnect). Surfaced in the UI/CLI
305    /// so the user knows which anti-censorship path is live.
306    active_transport: Arc<Mutex<Option<TransportId>>>,
307    /// huddle 1.0: the full set of transport doors resolved at startup (for
308    /// the UI/CLI listing — includes unavailable ones with a reason).
309    transport_profiles: Arc<Vec<TransportProfile>>,
310    /// huddle 1.1.4: the resolved Tor SOCKS5 proxy address (CLI/config →
311    /// `DEFAULT_TOR_SOCKS`). Stored so privacy-sensitive clearnet fetches
312    /// (the opt-in update check) can be routed through Tor instead of
313    /// leaking the client's IP onto the clearnet.
314    tor_socks: String,
315}
316
317/// huddle 1.0: how to reach the relay backend — the bundle of transport
318/// inputs resolved by `main.rs` (CLI + config) and handed to the core. The
319/// core turns these into the ordered set of [`TransportProfile`] doors.
320#[derive(Clone, Default)]
321pub struct TransportConfig {
322    /// The onion relay ws URL (`ws://<onion>.onion:80/ws`), or `None` for
323    /// `--no-server`. Resolved by the caller (includes the baked-in default).
324    pub onion_url: Option<String>,
325    /// A clearnet relay URL — `ws://<ip>:<port>/ws` or `wss://host/ws`. The
326    /// scheme decides which clearnet door (plain / TLS) is usable.
327    pub clearnet_url: Option<String>,
328    /// Local Tor SOCKS5 proxy (`None` → `DEFAULT_TOR_SOCKS`).
329    pub tor_socks: Option<String>,
330    /// Optional bridge line for the bridge door (Arti build / labeling).
331    pub tor_bridge: Option<String>,
332    /// Pin a single door by [`TransportId::as_str`] (CLI `--transport`).
333    pub pin: Option<String>,
334    /// Explicit fallback order as `TransportId::as_str` tokens (CLI
335    /// `--transport-order`).
336    pub order: Option<Vec<String>>,
337}
338
339impl TransportConfig {
340    /// An onion-only config (the common case + most tests).
341    pub fn onion_only(url: impl Into<String>) -> Self {
342        Self {
343            onion_url: Some(url.into()),
344            ..Default::default()
345        }
346    }
347}
348
349/// huddle 1.0: how a conversation's messages are currently reaching the
350/// other side. Status only — the app always picks the path automatically;
351/// this just makes the security context legible per chat.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum RoomTransport {
354    /// A live libp2p connection to a member (same LAN or a direct dial).
355    LanDirect,
356    /// No direct connection, but the relay is up (messages ride the relay /
357    /// its offline mailbox).
358    Relay,
359    /// Neither a direct connection nor the relay — messages only save locally.
360    Offline,
361}
362
363impl RoomTransport {
364    pub fn label(&self) -> &'static str {
365        match self {
366            RoomTransport::LanDirect => "lan",
367            RoomTransport::Relay => "relay",
368            RoomTransport::Offline => "offline",
369        }
370    }
371}
372
373/// huddle 1.2: whether a message typed into a room can actually leave this
374/// device right now. The UIs query this to gate the composer instead of
375/// optimistically echoing a message that silently reaches no one — the
376/// "I typed but nothing happened" failure. Distinct from `RoomTransport`,
377/// which is a pure status label.
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum SendReadiness {
380    /// A live transport exists (a direct LAN link to a member, or the relay).
381    /// The message will be delivered live, or — over the relay — reliably
382    /// queued in the recipient's offline mailbox until they reconnect.
383    Ready,
384    /// The relay is configured but not connected yet (booting, reconnecting,
385    /// or the Tor circuit is still coming up). Sending now would not leave the
386    /// device, so the UI should hold the message and show "connecting".
387    Connecting,
388    /// No transport at all — the relay is disabled (`--no-server`) and there's
389    /// no LAN link. Nothing typed here can reach the other party.
390    Disconnected,
391}
392
393impl SendReadiness {
394    /// True only when a send will actually go somewhere.
395    pub fn can_send(self) -> bool {
396        matches!(self, SendReadiness::Ready)
397    }
398
399    /// Short reason for the UI to show when the composer is gated.
400    pub fn reason(self) -> &'static str {
401        match self {
402            SendReadiness::Ready => "",
403            SendReadiness::Connecting => "connecting to relay — message held",
404            SendReadiness::Disconnected => "offline — no relay and no LAN link",
405        }
406    }
407}
408
409/// Phase D follow-up: minimum seconds between two opportunistic
410/// `host_addrs` dials to the same announcer fingerprint.
411const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
412
413/// huddle 0.5: minimum ms between two `PeerIdentified`-triggered
414/// re-broadcasts of our own `ProfileUpdate` to the same peer
415/// fingerprint. Prevents storm-on-reconnect on flaky transports.
416const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
417
418impl AppHandle {
419    pub async fn start() -> Result<Self> {
420        Self::start_with_options(NetworkMode::Server, 0, None, Vec::new(), TransportConfig::default())
421            .await
422    }
423
424    /// huddle 0.7.8: peek the persisted `mdns_enabled` setting without
425    /// starting the full AppHandle. Called by the client (`main.rs` /
426    /// huddle-gui) before `start_with_options` so the initial
427    /// `NetworkMode` reflects the user's saved preference — the in-app
428    /// "run LAN mDNS alongside the relay" toggle. The CLI `--mode` flag,
429    /// when present, still wins; clients only consult this when `--mode`
430    /// is absent.
431    ///
432    /// huddle 0.9.2: defaults **OFF** when unset. Since 0.8 the relay-only
433    /// `Server` mode is the default and libp2p is strictly opt-in, so an
434    /// unset preference must mean "no LAN swarm". (Pre-0.7.8 this defaulted
435    /// ON; that default predated the onion relay becoming the baseline.)
436    pub fn peek_mdns_enabled(master_key: Option<&[u8; 32]>) -> Result<bool> {
437        config::ensure_data_dir()?;
438        let db = storage::open_db(&config::db_path(), master_key)?;
439        let v = repo::get_setting(&db, "mdns_enabled")?
440            .map(|s| s == "1")
441            .unwrap_or(false);
442        Ok(v)
443    }
444
445    pub async fn start_with_options(
446        mode: NetworkMode,
447        port: u16,
448        master_key: Option<&[u8; 32]>,
449        relays: Vec<Multiaddr>,
450        transports: TransportConfig,
451    ) -> Result<Self> {
452        config::ensure_data_dir()?;
453        // Megolm session state is encrypted at rest with an HKDF subkey
454        // of the master key. With no master key (--no-master-passphrase /
455        // tests) it's persisted under the all-zero key, matching the
456        // unencrypted-DB story.
457        let session_persist_key = match master_key {
458            Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
459            None => [0u8; 32],
460        };
461        let db = storage::open_db(&config::db_path(), master_key)?;
462        Self::start_with_db_and_options(db, mode, port, session_persist_key, relays, transports).await
463    }
464
465    pub async fn start_with_db(db: Db) -> Result<Self> {
466        Self::start_with_db_and_options(
467            db,
468            NetworkMode::Mdns,
469            0,
470            [0u8; 32],
471            Vec::new(),
472            TransportConfig::default(),
473        )
474        .await
475    }
476
477    pub async fn start_with_db_and_options(
478        db: Db,
479        mode: NetworkMode,
480        port: u16,
481        session_persist_key: [u8; 32],
482        relays: Vec<Multiaddr>,
483        transports: TransportConfig,
484    ) -> Result<Self> {
485        // Ensure rustls has a CryptoProvider before any transport door builds a
486        // TLS config (the `wss://` clearnet relay). This is the innermost start
487        // funnel — `start`, `start_with_options`, and `start_with_db` all route
488        // here — so every consumer (GUI, TUI, tests) is covered. Idempotent;
489        // see `crate::install_default_crypto_provider`.
490        crate::install_default_crypto_provider();
491
492        let identity = Self::load_or_create_identity(&db)?;
493        let identity = Arc::new(identity);
494        info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
495
496        let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
497        // huddle 1.1.4: 1024 (was 256) gives a slow UI subscriber more
498        // headroom before it lags and drops AppEvents. A lagging receiver
499        // still recovers via authoritative resync (TUI grace-summary / GUI
500        // ~1s refresh), so this is resilience, not correctness.
501        let (app_event_tx, _) = broadcast::channel::<AppEvent>(1024);
502        // huddle 0.8: the default `Server` mode runs NO libp2p — the Tor
503        // onion relay is the only transport. `--mode mdns|direct` opts back
504        // into a libp2p swarm running alongside the relay. In `Server` mode
505        // `net_event_tx` is simply dropped, so the event processor (which
506        // only carries libp2p events) winds down; server messages reach
507        // `process_network_event` directly from `spawn_server_connection`.
508        let network = if mode.uses_libp2p() {
509            network::start_network_with(&identity, net_event_tx, mode, port, relays)?
510        } else {
511            network::start_network_disabled()
512        };
513
514        let active_rooms = Arc::new(Mutex::new(HashMap::new()));
515        let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
516        let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
517        let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
518        let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
519
520        // huddle 1.0: resolve the transport "doors" + the order to try them.
521        // CLI inputs (in `transports`) win over config.toml; the pin/order
522        // also fall back to saved settings, then the default most-private-
523        // first fallback.
524        let tor_socks = transports
525            .tor_socks
526            .clone()
527            .or_else(config::tor_socks)
528            .unwrap_or_else(|| DEFAULT_TOR_SOCKS.to_string());
529        // huddle 1.0: clearnet relay precedence is CLI/TransportConfig →
530        // config.toml → the persisted `clearnet_url` setting (what the GUI's
531        // "Set relay" writes). The DB value is filtered for empty so clearing
532        // the relay from the GUI (which writes "") resets to no clearnet door.
533        let clearnet_url = transports
534            .clearnet_url
535            .clone()
536            .or_else(config::clearnet_url)
537            .or_else(|| {
538                repo::get_setting(&db, "clearnet_url")
539                    .ok()
540                    .flatten()
541                    .filter(|s| !s.trim().is_empty())
542            })
543            // huddle 1.1: fall back to the operator's baked-in clearnet door
544            // (`DEFAULT_CLEARNET_URL`) so a fresh client reaches the relay over
545            // clearnet with zero config when Tor is unavailable. Gated on an
546            // onion relay being configured: the real binaries always bake in
547            // `DEFAULT_SERVER_URL`, while tests / libp2p-only embedders pass
548            // `onion_url: None` (`TransportConfig::default`) and must NOT get a
549            // network door they'd silently dial. Still tried only AFTER the
550            // onion (see `default_fallback_order`); any explicit CLI / config /
551            // saved-DB value above wins, and clearing the relay (empty DB
552            // value) reverts to this default rather than to "no clearnet".
553            .or_else(|| {
554                transports
555                    .onion_url
556                    .as_ref()
557                    .map(|_| DEFAULT_CLEARNET_URL.to_string())
558            });
559        let tor_bridge = transports.tor_bridge.clone().or_else(config::tor_bridge);
560        let transport_profiles = transport::builtin_profiles(
561            transports.onion_url.as_deref(),
562            clearnet_url.as_deref(),
563            &tor_socks,
564            tor_bridge.as_deref(),
565        );
566        let any_relay = transport_profiles.iter().any(|p| p.available());
567        let pin = transports
568            .pin
569            .as_deref()
570            .and_then(TransportId::from_str)
571            .or_else(|| {
572                repo::get_setting(&db, "transport_pin")
573                    .ok()
574                    .flatten()
575                    .as_deref()
576                    .and_then(TransportId::from_str)
577            });
578        let transport_order = if let Some(pin) = pin {
579            vec![pin]
580        } else {
581            transports
582                .order
583                .as_ref()
584                .map(|v| v.iter().filter_map(|s| TransportId::from_str(s)).collect::<Vec<_>>())
585                .filter(|v| !v.is_empty())
586                .or_else(|| {
587                    repo::get_setting(&db, "transport_order")
588                        .ok()
589                        .flatten()
590                        .map(|s| transport::parse_order(&s))
591                        .filter(|v| !v.is_empty())
592                })
593                .unwrap_or_else(transport::default_fallback_order)
594        };
595        let transport_profiles = Arc::new(transport_profiles);
596
597        let handle = Self {
598            identity,
599            network,
600            mode,
601            active_rooms,
602            discovered_rooms,
603            restorable_rooms,
604            connected_dial_addrs,
605            file_manager,
606            db,
607            session_persist_key,
608            sas_flows: Arc::new(Mutex::new(HashMap::new())),
609            pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
610            pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
611            nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
612            relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
613            host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
614            last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
615            pending_auto_dm_addrs: Arc::new(Mutex::new(HashSet::new())),
616            app_event_tx,
617            server_enabled: any_relay,
618            aux_subscriptions: Arc::new(Mutex::new(HashSet::new())),
619            active_transport: Arc::new(Mutex::new(None)),
620            transport_profiles: transport_profiles.clone(),
621            tor_socks,
622        };
623
624        handle.spawn_event_processor(net_event_rx);
625        handle.spawn_announcement_ticker();
626        handle.spawn_discovered_room_pruner();
627        handle.spawn_known_peer_reconnector();
628        handle.restore_rooms_from_db().await;
629        // huddle 1.0: subscribe to our own relay inbox so "add by HD-ID"
630        // contact requests reach us over the internet, not just over the LAN
631        // mesh. Registered in `aux_subscriptions` so the membership is
632        // re-asserted in every reconnect's `Hello` (see
633        // spawn_server_connection); the live call here also subscribes the
634        // gossipsub topic for the LAN path.
635        {
636            let inbox =
637                crate::network::protocol::inbox_room_id(handle.identity.fingerprint());
638            handle.aux_subscriptions.lock().unwrap().insert(inbox.clone());
639            handle.network.subscribe_room(inbox).await;
640        }
641        // huddle 0.8/1.0: now that active rooms are loaded, open the
642        // persistent relay connection (if any transport door is usable),
643        // trying the doors in `transport_order`. Connecting after restore
644        // means our `hello` carries the restored room ids + the inbox, so the
645        // server registers our memberships and flushes any offline mailbox.
646        if any_relay {
647            handle.spawn_server_connection(transport_order);
648        }
649        // huddle 0.7.7: prune any friend requests that aged out while
650        // we were offline. Best-effort — a DB failure here shouldn't
651        // block startup, so we log and move on.
652        if let Err(e) = repo::cleanup_expired_pending_friend_requests(&handle.db, now_unix()) {
653            warn!(%e, "failed to sweep expired pending friend requests");
654        }
655        // huddle 1.0: same 3-day TTL sweep for relay-inbox contact requests.
656        if let Err(e) = repo::cleanup_expired_pending_contact_requests(&handle.db, now_unix()) {
657            warn!(%e, "failed to sweep expired pending contact requests");
658        }
659
660        Ok(handle)
661    }
662
663    pub fn mode(&self) -> NetworkMode {
664        self.mode
665    }
666
667    /// huddle 0.8: whether the centralized-server connection is currently
668    /// up. Used by the TUI status line and by tests waiting for connect.
669    pub fn server_connected(&self) -> bool {
670        self.network.has_server()
671    }
672
673    /// huddle 0.8: whether a centralized server was configured at startup
674    /// (vs `--no-server` / a `None` server URL). The TUI uses this to
675    /// decide whether to render the relay indicator at all — there's no
676    /// point showing a "disconnected" badge for a feature the user turned
677    /// off.
678    pub fn server_enabled(&self) -> bool {
679        self.server_enabled
680    }
681
682    /// huddle 1.0: the transport door the relay is currently connected
683    /// through (`None` when not connected). For the UI/CLI status line.
684    pub fn active_transport(&self) -> Option<TransportId> {
685        *self.active_transport.lock().unwrap()
686    }
687
688    /// Human label for the live transport door, e.g. "Tor onion (system Tor)".
689    pub fn active_transport_label(&self) -> Option<&'static str> {
690        self.active_transport().map(|id| id.label())
691    }
692
693    /// huddle 1.0: all transport doors (available + unavailable-with-reason)
694    /// for the Settings pane and the `huddle transports` listing.
695    pub fn transport_profiles(&self) -> Vec<TransportProfile> {
696        self.transport_profiles.as_ref().clone()
697    }
698
699    /// huddle 1.0: how messages to `room_id` are currently reaching peers —
700    /// a live libp2p connection (LAN/direct), the relay, or nobody. Used by
701    /// the per-chat transport indicator. Status only.
702    pub fn room_transport(&self, room_id: &str) -> RoomTransport {
703        let members = self.room_members(room_id);
704        if !members.is_empty() {
705            let connected = self.connected_dial_addrs.lock().unwrap().clone();
706            if !connected.is_empty() {
707                if let Ok(known) = repo::list_known_peers(&self.db) {
708                    let lan_live = known.iter().any(|p| {
709                        p.fingerprint.as_deref().is_some_and(|fp| {
710                            members.iter().any(|m| m == fp) && connected.contains_key(&p.address)
711                        })
712                    });
713                    if lan_live {
714                        return RoomTransport::LanDirect;
715                    }
716                }
717            }
718        }
719        if self.server_connected() {
720            RoomTransport::Relay
721        } else {
722            RoomTransport::Offline
723        }
724    }
725
726    /// huddle 1.2: can a message typed into `room_id` actually be delivered
727    /// right now? Drives composer gating in both front-ends so we never show
728    /// an optimistic local echo for a message that reached no one. A `Relay`
729    /// or `LanDirect` transport means Ready (the relay mailboxes an offline
730    /// partner, so it still counts). `Offline` resolves to `Connecting` when a
731    /// relay is configured (it should come up shortly) or `Disconnected` when
732    /// no relay is configured at all.
733    pub fn room_send_readiness(&self, room_id: &str) -> SendReadiness {
734        match self.room_transport(room_id) {
735            RoomTransport::LanDirect | RoomTransport::Relay => SendReadiness::Ready,
736            RoomTransport::Offline => {
737                if self.server_enabled() {
738                    SendReadiness::Connecting
739                } else {
740                    SendReadiness::Disconnected
741                }
742            }
743        }
744    }
745
746    pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
747        self.app_event_tx.subscribe()
748    }
749
750    pub fn fingerprint(&self) -> &str {
751        self.identity.fingerprint()
752    }
753
754    pub fn peer_id(&self) -> PeerId {
755        self.identity.peer_id()
756    }
757
758    /// huddle 0.7.11: bind an invite link to our Ed25519 identity by
759    /// signing it. The receiver re-derives the fingerprint from the
760    /// embedded pubkey and rejects the invite if any signed field
761    /// (host_multiaddr, fingerprint, room id/name/encrypted/salt/
762    /// creator_fp/owner_list, signed_at_ms) was tampered with.
763    pub fn sign_invite(&self, invite: crate::invite::InviteLink) -> Result<crate::invite::InviteLink> {
764        crate::invite::sign_invite(&self.identity, invite)
765    }
766
767    pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
768        let now = now_unix();
769        let our_fp = self.identity.fingerprint().to_string();
770        let mut by_id: HashMap<String, DiscoveredRoom> = self
771            .discovered_rooms
772            .lock()
773            .unwrap()
774            .clone();
775
776        // Merge in rooms we're currently in — gossipsub doesn't echo our
777        // own announcements back to us, so without this our own hosted
778        // rooms wouldn't appear in the lobby.
779        for room in self.active_rooms.lock().unwrap().values() {
780            let entry = DiscoveredRoom {
781                room_id: room.info.id.clone(),
782                name: room.info.name.clone(),
783                encrypted: room.info.encrypted,
784                member_count: room.members.len() as u32,
785                creator_fingerprint: room.info.creator_fingerprint.clone(),
786                last_seen: now,
787                restorable: false,
788                host_addrs: Vec::new(),
789                kind: room.info.kind,
790            };
791            by_id
792                .entry(room.info.id.clone())
793                .and_modify(|d| {
794                    d.last_seen = now;
795                    if entry.member_count > d.member_count {
796                        d.member_count = entry.member_count;
797                    }
798                    d.restorable = false;
799                    d.kind = entry.kind;
800                })
801                .or_insert(entry);
802        }
803
804        // Encrypted rooms we have on disk but haven't rejoined this
805        // session. Only surface them when no fresh discovery / active
806        // entry exists for the same room.
807        for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
808            if by_id.contains_key(id) {
809                continue;
810            }
811            by_id.insert(
812                id.clone(),
813                DiscoveredRoom {
814                    room_id: id.clone(),
815                    name: stored.name.clone(),
816                    encrypted: stored.encrypted,
817                    member_count: 0,
818                    creator_fingerprint: stored.creator_fingerprint.clone(),
819                    last_seen: stored.last_active.unwrap_or(stored.created_at),
820                    restorable: true,
821                    host_addrs: Vec::new(),
822                    kind: stored.kind,
823                },
824            );
825        }
826
827        // huddle 0.7 DM-visibility filter: drop any `Direct` room we're
828        // not a member of. A DM's canonical room_id is
829        // `canonical_dm_room_id(fp_a, fp_b)`. If we're one of the pair we
830        // pass; otherwise we drop. Honest 0.7+ peers enforce this at the
831        // consumer; combined with the canonical-ID scheme it keeps DMs
832        // out of any third party's sidebar even if they happen to relay
833        // the gossipsub announcement.
834        by_id.retain(|room_id, d| {
835            if d.kind != RoomKind::Direct {
836                return true;
837            }
838            // Active rooms we host pass unconditionally — we always know
839            // we're a member of our own DM.
840            if self
841                .active_rooms
842                .lock()
843                .unwrap()
844                .contains_key(room_id)
845            {
846                return true;
847            }
848            // Otherwise: the announcer must be the other partner, AND
849            // the canonical pair must include us.
850            canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
851        });
852
853        let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
854        v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
855        v
856    }
857
858    /// huddle 0.7: returns the fingerprint of the other party in a 1-1
859    /// DM. `None` for rooms that are `Group`, missing, or somehow have a
860    /// non-2-member state. Used by the DM-pane header to render the
861    /// partner's username + HD-ID.
862    pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
863        let our_fp = self.identity.fingerprint().to_string();
864        let rooms = self.active_rooms.lock().unwrap();
865        let room = rooms.get(room_id)?;
866        if room.info.kind != RoomKind::Direct {
867            return None;
868        }
869        room.members
870            .iter()
871            .find(|m| **m != our_fp)
872            .cloned()
873    }
874
875    pub fn active_room_ids(&self) -> Vec<String> {
876        self.active_rooms.lock().unwrap().keys().cloned().collect()
877    }
878
879    pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
880        self.active_rooms
881            .lock()
882            .unwrap()
883            .get(room_id)
884            .map(|r| r.info.clone())
885    }
886
887    pub fn room_members(&self, room_id: &str) -> Vec<String> {
888        self.active_rooms
889            .lock()
890            .unwrap()
891            .get(room_id)
892            .map(|r| {
893                let mut m: Vec<String> = r.members.iter().cloned().collect();
894                m.sort();
895                m
896            })
897            .unwrap_or_default()
898    }
899
900    pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
901        repo::get_room_messages(&self.db, room_id, limit)
902    }
903
904    pub fn search_room_messages(
905        &self,
906        room_id: &str,
907        query: &str,
908        limit: i64,
909    ) -> Result<Vec<repo::StoredRoomMessage>> {
910        repo::search_room_messages(&self.db, room_id, query, limit)
911    }
912
913    /// Create a new room. Returns its room_id.
914    ///
915    /// huddle 0.7: `kind` is now required. `RoomKind::Group` (the default)
916    /// preserves pre-0.7 behavior. `RoomKind::Direct` is reserved for
917    /// callers that have already computed a deterministic DM room_id via
918    /// `canonical_dm_room_id` — most clients should call `start_direct`
919    /// instead, which handles idempotency, kind, and naming.
920    pub async fn start_room(
921        &self,
922        name: &str,
923        encrypted: bool,
924        passphrase: Option<&str>,
925        kind: RoomKind,
926    ) -> Result<String> {
927        if encrypted && passphrase.is_none() {
928            return Err(HuddleError::Other(
929                "encrypted room requires a passphrase".into(),
930            ));
931        }
932
933        let created_at = now_unix();
934        let creator_fp = self.identity.fingerprint().to_string();
935        let room_id = derive_room_id(&creator_fp, name, created_at);
936
937        let (passphrase_salt, passphrase_key) = if encrypted {
938            let salt = passphrase::random_salt();
939            let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
940            (Some(salt.to_vec()), Some(key))
941        } else {
942            (None, None)
943        };
944
945        let info = StoredRoom {
946            id: room_id.clone(),
947            name: name.to_string(),
948            creator_fingerprint: creator_fp.clone(),
949            encrypted,
950            passphrase_salt: passphrase_salt.clone(),
951            created_at,
952            last_active: Some(created_at),
953            kind,
954        };
955        repo::insert_room(&self.db, &info)?;
956
957        let crypto = if encrypted {
958            Some(RoomCrypto::new_for_room(
959                self.db.clone(),
960                room_id.clone(),
961                creator_fp.clone(),
962                self.session_persist_key,
963            )?)
964        } else {
965            None
966        };
967
968        let mut members = HashSet::new();
969        members.insert(creator_fp.clone());
970
971        // Phase B: the room creator is the first owner. Persisted now so
972        // the very first announcement includes our fingerprint in
973        // `owner_fingerprints`, letting joiners know who's authorized.
974        repo::upsert_room_member(
975            &self.db,
976            &StoredRoomMember {
977                room_id: room_id.clone(),
978                peer_id: String::new(),
979                fingerprint: creator_fp.clone(),
980                last_seen: Some(created_at),
981                verified: true, // we trust ourselves
982                ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
983                role: "owner".into(),
984            },
985        )?;
986
987        self.active_rooms.lock().unwrap().insert(
988            room_id.clone(),
989            ActiveRoom {
990                info: info.clone(),
991                crypto,
992                passphrase_key,
993                members,
994                typers: HashMap::new(),
995                read_only: false,
996                issued_codes: Vec::new(),
997            },
998        );
999
1000        self.network.subscribe_room(room_id.clone()).await;
1001        self.announce_room_now(&info, 1).await;
1002
1003        // Broadcast our presence in the room (with our wrapped session key
1004        // if encrypted). Use a small delay so the subscription propagates.
1005        let app = self.clone();
1006        let rid = room_id.clone();
1007        tokio::spawn(async move {
1008            tokio::time::sleep(Duration::from_millis(500)).await;
1009            if let Err(e) = app.broadcast_member_announce(&rid).await {
1010                warn!(%e, "broadcast member announce");
1011            }
1012        });
1013
1014        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1015            room_id: room_id.clone(),
1016        });
1017
1018        Ok(room_id)
1019    }
1020
1021    /// huddle 0.7.1: start (or open) a 1-1 DM with `partner_fingerprint`.
1022    ///
1023    /// Idempotent across peers and reopens:
1024    /// 1. Refuses to DM yourself.
1025    /// 2. Computes `room_id = canonical_dm_room_id(our_fp, partner_fp)`.
1026    ///    Both peers, regardless of who clicks first, derive identical
1027    ///    IDs.
1028    /// 3. If a DM room already exists locally (active or stored), returns
1029    ///    its id — no new room, no second announcement.
1030    /// 4. Otherwise creates a `RoomKind::Direct`, **end-to-end encrypted**
1031    ///    room. The key is derived from Ed25519→X25519 ECDH between the
1032    ///    two parties' identity keys (see `crypto::dm::derive_dm_key`).
1033    ///    No shared passphrase, no central key agreement — both peers
1034    ///    independently derive the same 32-byte room key from their
1035    ///    own seed + the other's pubkey.
1036    /// 5. If we don't yet know the partner's Ed25519 pubkey, the room
1037    ///    is still created encrypted; the key is derived lazily once
1038    ///    `MemberAnnounce` arrives with the partner's pubkey, after
1039    ///    which we send our wrapped Megolm session key in a follow-up
1040    ///    announce.
1041    /// 6. Subscribes to the room topic and announces on the global topic.
1042    ///    The announcement is visibility-filtered at honest 0.7+ peers,
1043    ///    so only the partner sees it in their `discovered_rooms()`.
1044    pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
1045        let our_fp = self.identity.fingerprint().to_string();
1046        if partner_fingerprint == our_fp {
1047            return Err(HuddleError::Other("cannot DM yourself".into()));
1048        }
1049        let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
1050        // huddle 1.2: ensure relay traffic for this DM is delivered straight
1051        // to the partner's fingerprint (works even before they subscribe).
1052        self.network
1053            .register_dm(room_id.clone(), partner_fingerprint.to_string());
1054
1055        // huddle 1.0: a DM is a relationship — record the partner in the
1056        // durable Contacts book so they persist (and stay chattable over the
1057        // relay) even after they leave the LAN. Idempotent; best-effort.
1058        let _ = self.add_contact(partner_fingerprint, "dm");
1059
1060        // Idempotent reopen: if the room already exists on disk or in
1061        // memory, surface its id without creating a duplicate. This
1062        // handles both "I already DM'd them" and "they DM'd me first
1063        // and we auto-accepted" paths.
1064        if self.active_rooms.lock().unwrap().contains_key(&room_id) {
1065            let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1066                room_id: room_id.clone(),
1067            });
1068            return Ok(room_id);
1069        }
1070        if repo::get_room(&self.db, &room_id)?.is_some() {
1071            // Re-bootstrap the in-memory active room from disk.
1072            return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
1073        }
1074
1075        let created_at = now_unix();
1076        // The name is internal/derived — the DM pane renders the partner
1077        // username + HD-ID instead. Including the short fp keeps the row
1078        // navigable in `sqlite3` if someone digs into the DB.
1079        let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
1080
1081        // huddle 0.7.1: DMs are always encrypted. The salt slot stores
1082        // the canonical room_id (16 raw bytes from the SHA-256 prefix)
1083        // so a re-bootstrap can re-derive the same key. The actual key
1084        // comes from ECDH below, not from this salt — but we keep the
1085        // salt slot non-NULL so legacy code paths (which assume
1086        // encrypted rooms have salts) don't choke.
1087        let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
1088        let info = StoredRoom {
1089            id: room_id.clone(),
1090            name,
1091            creator_fingerprint: our_fp.clone(),
1092            encrypted: true,
1093            passphrase_salt: Some(dm_salt),
1094            created_at,
1095            last_active: Some(created_at),
1096            kind: RoomKind::Direct,
1097        };
1098        repo::insert_room(&self.db, &info)?;
1099
1100        let mut members = HashSet::new();
1101        members.insert(our_fp.clone());
1102        repo::upsert_room_member(
1103            &self.db,
1104            &StoredRoomMember {
1105                room_id: room_id.clone(),
1106                peer_id: String::new(),
1107                fingerprint: our_fp.clone(),
1108                last_seen: Some(created_at),
1109                verified: true,
1110                ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1111                role: "member".into(),
1112            },
1113        )?;
1114
1115        // Try to derive the ECDH key now. If the partner's pubkey
1116        // hasn't been observed yet (we know their fingerprint from a
1117        // QR / invite / username lookup, but never seen a signed
1118        // message from them), the key is None and gets populated by
1119        // the `MemberAnnounce` handler below the moment partner's
1120        // first announcement lands.
1121        let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
1122
1123        // Always create our outbound Megolm session so we can encrypt
1124        // *something* the moment the key materializes. RoomCrypto
1125        // works the same as it does for group rooms — the only
1126        // difference is where `passphrase_key` comes from.
1127        let crypto = Some(RoomCrypto::new_for_room(
1128            self.db.clone(),
1129            room_id.clone(),
1130            our_fp.clone(),
1131            self.session_persist_key,
1132        )?);
1133
1134        self.active_rooms.lock().unwrap().insert(
1135            room_id.clone(),
1136            ActiveRoom {
1137                info: info.clone(),
1138                crypto,
1139                passphrase_key,
1140                members,
1141                typers: HashMap::new(),
1142                read_only: false,
1143                issued_codes: Vec::new(),
1144            },
1145        );
1146
1147        self.network.subscribe_room(room_id.clone()).await;
1148        self.announce_room_now(&info, 1).await;
1149
1150        let app = self.clone();
1151        let rid = room_id.clone();
1152        tokio::spawn(async move {
1153            tokio::time::sleep(Duration::from_millis(500)).await;
1154            if let Err(e) = app.broadcast_member_announce(&rid).await {
1155                warn!(%e, "broadcast member announce for DM");
1156            }
1157        });
1158
1159        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1160            room_id: room_id.clone(),
1161        });
1162        Ok(room_id)
1163    }
1164
1165    /// huddle 0.7.1: derive a DM key from a base64-encoded partner
1166    /// pubkey. Mirrors `try_derive_dm_key` but operates on a pubkey we
1167    /// just received (e.g. via `MemberAnnounce.sender_ed25519_pubkey`)
1168    /// without re-querying the DB.
1169    fn derive_dm_key_from_pubkey_b64(
1170        &self,
1171        room_id: &str,
1172        pubkey_b64: &str,
1173    ) -> Option<[u8; KEY_LEN]> {
1174        let bytes = B64.decode(pubkey_b64).ok()?;
1175        if bytes.len() != 32 {
1176            return None;
1177        }
1178        let mut pubkey = [0u8; 32];
1179        pubkey.copy_from_slice(&bytes);
1180        // huddle 1.1.4: wipe our copy of the identity secret on drop.
1181        let our_seed = zeroize::Zeroizing::new(self.identity.secret_bytes());
1182        match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
1183            Ok(k) => Some(k),
1184            Err(e) => {
1185                warn!(%e, "DM key derivation (from announce) failed");
1186                None
1187            }
1188        }
1189    }
1190
1191    /// huddle 0.7.1: look up partner's Ed25519 pubkey (from anywhere
1192    /// we've persisted it) and derive the DM room key via ECDH. Returns
1193    /// `None` when the pubkey isn't known yet — the caller proceeds
1194    /// without a key and the `MemberAnnounce` handler retries later.
1195    fn try_derive_dm_key(
1196        &self,
1197        room_id: &str,
1198        partner_fingerprint: &str,
1199    ) -> Option<[u8; KEY_LEN]> {
1200        let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
1201            .ok()
1202            .flatten()?;
1203        let bytes = B64.decode(&pubkey_b64).ok()?;
1204        if bytes.len() != 32 {
1205            return None;
1206        }
1207        let mut pubkey = [0u8; 32];
1208        pubkey.copy_from_slice(&bytes);
1209        // huddle 1.1.4: wipe our copy of the identity secret on drop.
1210        let our_seed = zeroize::Zeroizing::new(self.identity.secret_bytes());
1211        match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
1212            Ok(k) => Some(k),
1213            Err(e) => {
1214                warn!(%e, %partner_fingerprint, "DM key derivation failed");
1215                None
1216            }
1217        }
1218    }
1219
1220    /// Internal: re-hydrate an existing on-disk DM room into
1221    /// `active_rooms` and re-subscribe / re-announce. Used by
1222    /// `start_direct` when the room exists on disk but not in memory
1223    /// (e.g. process restart) and by the auto-accept path when a DM
1224    /// announcement arrives from the partner.
1225    async fn bootstrap_direct_room(
1226        &self,
1227        room_id: &str,
1228        partner_fingerprint: &str,
1229    ) -> Result<String> {
1230        let our_fp = self.identity.fingerprint().to_string();
1231        // huddle 1.2: re-register direct-delivery routing for this restored DM
1232        // so its relay traffic addresses the partner by fingerprint.
1233        self.network
1234            .register_dm(room_id.to_string(), partner_fingerprint.to_string());
1235        let info = repo::get_room(&self.db, room_id)?
1236            .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
1237        let mut members = HashSet::new();
1238        members.insert(our_fp.clone());
1239        members.insert(partner_fingerprint.to_string());
1240
1241        // Pull persisted members so re-bootstrap doesn't lose them.
1242        if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
1243            for m in stored_members {
1244                members.insert(m.fingerprint);
1245            }
1246        }
1247
1248        // huddle 0.7.1: rehydrate the ECDH key + Megolm session if the
1249        // partner's pubkey is on disk (which it always is after at
1250        // least one previous MemberAnnounce). For older DMs that
1251        // pre-date 0.7.1 (when DMs were unencrypted on the room
1252        // layer), `info.encrypted` is false — preserve that and skip
1253        // the ECDH derivation; the room continues operating as it did
1254        // before. New 0.7.1+ DMs all have `encrypted = true`.
1255        let (passphrase_key, crypto) = if info.encrypted {
1256            let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
1257            // huddle 0.7.11: bubble up the error instead of .expect. The
1258            // inbound-DM auto-bootstrap path spawns this on its own task;
1259            // a transient DB write failure used to panic the task and
1260            // silently kill all subsequent DM bootstraps.
1261            let c = match RoomCrypto::load(
1262                self.db.clone(),
1263                room_id.to_string(),
1264                our_fp.clone(),
1265                self.session_persist_key,
1266            )? {
1267                Some(c) => Some(c),
1268                None => Some(RoomCrypto::new_for_room(
1269                    self.db.clone(),
1270                    room_id.to_string(),
1271                    our_fp.clone(),
1272                    self.session_persist_key,
1273                )?),
1274            };
1275            (pk, c)
1276        } else {
1277            (None, None)
1278        };
1279
1280        self.active_rooms.lock().unwrap().insert(
1281            room_id.to_string(),
1282            ActiveRoom {
1283                info: info.clone(),
1284                crypto,
1285                passphrase_key,
1286                members,
1287                typers: HashMap::new(),
1288                read_only: false,
1289                issued_codes: Vec::new(),
1290            },
1291        );
1292
1293        self.network.subscribe_room(room_id.to_string()).await;
1294        self.announce_room_now(&info, 2).await;
1295
1296        let app = self.clone();
1297        let rid = room_id.to_string();
1298        tokio::spawn(async move {
1299            tokio::time::sleep(Duration::from_millis(500)).await;
1300            if let Err(e) = app.broadcast_member_announce(&rid).await {
1301                warn!(%e, "broadcast member announce on DM bootstrap");
1302            }
1303        });
1304
1305        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1306            room_id: room_id.to_string(),
1307        });
1308        Ok(room_id.to_string())
1309    }
1310
1311    /// Join an existing room. The room may come from a live announcement
1312    /// (preferred), our restorable set, or the DB directly — whichever has
1313    /// the freshest copy. For encrypted rooms `passphrase` is required.
1314    pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
1315        // Resolve room metadata from the freshest available source.
1316        let (name, creator_fingerprint, encrypted, salt_opt) = {
1317            if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
1318                let salt = self.get_room_salt(room_id);
1319                (d.name, d.creator_fingerprint, d.encrypted, salt)
1320            } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
1321            {
1322                (
1323                    stored.name,
1324                    stored.creator_fingerprint,
1325                    stored.encrypted,
1326                    stored.passphrase_salt,
1327                )
1328            } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
1329                (
1330                    stored.name,
1331                    stored.creator_fingerprint,
1332                    stored.encrypted,
1333                    stored.passphrase_salt,
1334                )
1335            } else {
1336                return Err(HuddleError::Other(format!("room {room_id} not found")));
1337            }
1338        };
1339
1340        if encrypted && passphrase.is_none() {
1341            return Err(HuddleError::Other(
1342                "encrypted room requires a passphrase".into(),
1343            ));
1344        }
1345
1346        let passphrase_key = if encrypted {
1347            let salt = salt_opt
1348                .clone()
1349                .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
1350            Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
1351        } else {
1352            None
1353        };
1354
1355        // huddle 0.7: preserve the kind that came from the announcement
1356        // / restorable cache / DB. If we don't have it (very old row),
1357        // default to Group — matches the schema column default and the
1358        // back-fill policy.
1359        let kind = self
1360            .discovered_rooms
1361            .lock()
1362            .unwrap()
1363            .get(room_id)
1364            .map(|d| d.kind)
1365            .or_else(|| {
1366                repo::get_room(&self.db, room_id)
1367                    .ok()
1368                    .flatten()
1369                    .map(|r| r.kind)
1370            })
1371            .unwrap_or_default();
1372
1373        let info = StoredRoom {
1374            id: room_id.to_string(),
1375            name,
1376            creator_fingerprint,
1377            encrypted,
1378            passphrase_salt: salt_opt.clone(),
1379            created_at: now_unix(),
1380            last_active: Some(now_unix()),
1381            kind,
1382        };
1383        repo::insert_room(&self.db, &info)?;
1384
1385        let crypto = if encrypted {
1386            // Reuse persisted Megolm sessions on re-join; only mint a fresh
1387            // outbound session when nothing is stored for this room yet.
1388            let our_fp = self.identity.fingerprint().to_string();
1389            let existing = RoomCrypto::load(
1390                self.db.clone(),
1391                room_id.to_string(),
1392                our_fp.clone(),
1393                self.session_persist_key,
1394            )?;
1395            Some(match existing {
1396                Some(c) => c,
1397                None => RoomCrypto::new_for_room(
1398                    self.db.clone(),
1399                    room_id.to_string(),
1400                    our_fp,
1401                    self.session_persist_key,
1402                )?,
1403            })
1404        } else {
1405            None
1406        };
1407
1408        let mut members = HashSet::new();
1409        members.insert(self.identity.fingerprint().to_string());
1410
1411        self.active_rooms.lock().unwrap().insert(
1412            room_id.to_string(),
1413            ActiveRoom {
1414                info: info.clone(),
1415                crypto,
1416                passphrase_key,
1417                members,
1418                typers: HashMap::new(),
1419                read_only: false,
1420                issued_codes: Vec::new(),
1421            },
1422        );
1423        // No longer "restorable" now that we've rejoined.
1424        self.restorable_rooms.lock().unwrap().remove(room_id);
1425
1426        self.network.subscribe_room(room_id.to_string()).await;
1427
1428        let app = self.clone();
1429        let rid = room_id.to_string();
1430        tokio::spawn(async move {
1431            tokio::time::sleep(Duration::from_millis(500)).await;
1432            if let Err(e) = app.broadcast_member_announce(&rid).await {
1433                warn!(%e, "broadcast member announce");
1434            }
1435            // Ask existing members for their session keys.
1436            let req = RoomMessage::SessionKeyRequest {
1437                requester_fingerprint: app.identity.fingerprint().to_string(),
1438            };
1439            if let Ok(bytes) = encode_wire(&req) {
1440                app.network.publish_room_message(rid.clone(), bytes).await;
1441            }
1442        });
1443
1444        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1445            room_id: room_id.to_string(),
1446        });
1447
1448        Ok(())
1449    }
1450
1451    /// Walk the rooms table at startup. Non-encrypted rooms and DMs are
1452    /// silently restored (subscribed + re-announced). Encrypted *group*
1453    /// rooms get added to `restorable_rooms` so the lobby surfaces them
1454    /// and the user can re-enter via the join flow with the passphrase.
1455    ///
1456    /// huddle 1.0: DMs (always encrypted) are now fully re-activated here
1457    /// rather than parked — their key derives from our identity + the
1458    /// partner's persisted pubkey, no passphrase needed — so DM chat keeps
1459    /// flowing continuously across restarts and across networks (relay
1460    /// mailbox + LAN), instead of going dormant until manually reopened.
1461    async fn restore_rooms_from_db(&self) {
1462        let rooms = match repo::list_rooms(&self.db) {
1463            Ok(v) => v,
1464            Err(e) => {
1465                warn!(%e, "list rooms on restore");
1466                return;
1467            }
1468        };
1469        let our_fp = self.identity.fingerprint().to_string();
1470        let count = rooms.len();
1471        for info in rooms {
1472            // DMs: re-activate fully (key derives from identity + the
1473            // partner's persisted pubkey, no passphrase). Keeps DMs live so
1474            // relay-delivered messages are handled, not dropped.
1475            if info.encrypted && info.kind == RoomKind::Direct {
1476                let partner = repo::list_room_members(&self.db, &info.id)
1477                    .ok()
1478                    .into_iter()
1479                    .flatten()
1480                    .map(|m| m.fingerprint)
1481                    .find(|fp| *fp != our_fp);
1482                match partner {
1483                    Some(partner_fp) => {
1484                        if let Err(e) = self.bootstrap_direct_room(&info.id, &partner_fp).await {
1485                            warn!(%e, room_id = %info.id, "restore: DM bootstrap failed; parking as restorable");
1486                            self.restorable_rooms
1487                                .lock()
1488                                .unwrap()
1489                                .insert(info.id.clone(), info);
1490                        } else {
1491                            info!(room_id = %info.id, "restored DM");
1492                        }
1493                    }
1494                    // DM created but never reciprocated — partner pubkey
1495                    // unknown, nothing to re-activate. Park it (no key, no
1496                    // history anyway).
1497                    None => {
1498                        self.restorable_rooms
1499                            .lock()
1500                            .unwrap()
1501                            .insert(info.id.clone(), info);
1502                    }
1503                }
1504                continue;
1505            }
1506            // Encrypted GROUP rooms need a passphrase held in memory to
1507            // decrypt — park them as restorable for the user to re-enter.
1508            if info.encrypted {
1509                self.restorable_rooms
1510                    .lock()
1511                    .unwrap()
1512                    .insert(info.id.clone(), info);
1513                continue;
1514            }
1515            let mut members = HashSet::new();
1516            members.insert(our_fp.clone());
1517            if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1518                for m in stored_members {
1519                    members.insert(m.fingerprint);
1520                }
1521            }
1522            let member_count = members.len() as u32;
1523            self.active_rooms.lock().unwrap().insert(
1524                info.id.clone(),
1525                ActiveRoom {
1526                    info: info.clone(),
1527                    crypto: None,
1528                    passphrase_key: None,
1529                    members,
1530                    typers: HashMap::new(),
1531                    read_only: false,
1532                    issued_codes: Vec::new(),
1533                },
1534            );
1535            self.network.subscribe_room(info.id.clone()).await;
1536            self.announce_room_now(&info, member_count).await;
1537            info!(room_id = %info.id, name = %info.name, "restored room");
1538        }
1539        if count > 0 {
1540            debug!(count, "restored rooms from db");
1541        }
1542    }
1543
1544    /// Leave a room. Returns `true` when the `MemberLeave` notice was
1545    /// handed to the network layer, `false` when it couldn't be encoded
1546    /// (peers then only notice via the discovered-room TTL). The local
1547    /// leave always succeeds regardless.
1548    pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1549        // Broadcast a signed leave notice before unsubscribing. huddle
1550        // 0.7.11: MemberLeave is now signed so peers can't spoof another
1551        // member's leave to evict them from honest rosters.
1552        let leave_msg = RoomMessage::MemberLeave {
1553            sender_fingerprint: self.identity.fingerprint().to_string(),
1554        };
1555        let dispatched = match crate::crypto::sign_message(&self.identity, &leave_msg)
1556            .and_then(|env| {
1557                crate::network::protocol::encode_wire_signed(&env)
1558                    .map_err(|e| HuddleError::Session(format!("encode signed leave: {e}")))
1559            }) {
1560            Ok(bytes) => {
1561                self.network
1562                    .publish_room_message(room_id.to_string(), bytes)
1563                    .await;
1564                true
1565            }
1566            Err(e) => {
1567                warn!(%e, %room_id, "failed to sign+encode MemberLeave notice");
1568                false
1569            }
1570        };
1571
1572        self.active_rooms.lock().unwrap().remove(room_id);
1573        self.network.unsubscribe_room(room_id.to_string()).await;
1574
1575        let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1576            room_id: room_id.to_string(),
1577        });
1578        Ok(dispatched)
1579    }
1580
1581    pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1582        let our_fp = self.identity.fingerprint().to_string();
1583        let msg = {
1584            let mut rooms = self.active_rooms.lock().unwrap();
1585            let room = rooms
1586                .get_mut(room_id)
1587                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1588
1589            if room.read_only {
1590                return Err(HuddleError::Other(
1591                    "this room is read-only — you joined via code without the passphrase. Ask an owner for the passphrase or wait for a key rotation that includes you.".into(),
1592                ));
1593            }
1594
1595            if room.info.encrypted {
1596                let crypto = room
1597                    .crypto
1598                    .as_mut()
1599                    .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1600                let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1601                RoomMessage::Encrypted {
1602                    sender_fingerprint: our_fp.clone(),
1603                    session_id,
1604                    ciphertext_b64: base64::Engine::encode(
1605                        &base64::engine::general_purpose::STANDARD,
1606                        &ct_bytes,
1607                    ),
1608                }
1609            } else {
1610                RoomMessage::Plain {
1611                    sender_fingerprint: our_fp.clone(),
1612                    body: body.to_string(),
1613                }
1614            }
1615        };
1616
1617        let bytes = encode_wire(&msg)?;
1618        self.network
1619            .publish_room_message(room_id.to_string(), bytes)
1620            .await;
1621
1622        let now = now_unix();
1623        let msg_id =
1624            repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1625        repo::update_room_last_active(&self.db, room_id, now)?;
1626
1627        let _ = self.app_event_tx.send(AppEvent::MessageSent {
1628            room_id: room_id.to_string(),
1629            body: body.to_string(),
1630            message_id: msg_id,
1631        });
1632
1633        Ok(())
1634    }
1635
1636    pub async fn shutdown(&self) {
1637        self.network.shutdown().await;
1638    }
1639
1640    // -------------------------------------------------------------------
1641    // Dial / known peers
1642    // -------------------------------------------------------------------
1643
1644    /// Dial a peer by a user-entered address. Accepts:
1645    /// - `1.2.3.4:9000`
1646    /// - `[fe80::1]:9000`
1647    /// - `/ip4/.../tcp/...[/p2p/<peer>]` (raw multiaddr)
1648    /// huddle 0.5.1: resolve an HD- ID or username back to a dialable
1649    /// multiaddr and dial it.
1650    ///
1651    /// `input` is matched against, in order:
1652    /// 1. an `HD-XXXX-...` prefixed string → strip prefix + lowercase to
1653    ///    canonical fingerprint;
1654    /// 2. a raw 24-char hex run (with or without dashes) → group into
1655    ///    4-char blocks and lowercase;
1656    /// 3. otherwise → treat as a username and look up `peer_profiles`.
1657    ///
1658    /// Resolution to an address: scan `discovered_rooms` for a room
1659    /// whose `creator_fingerprint` matches; take the first `host_addrs`
1660    /// entry. Falls back to the `known_peers` table for users we've
1661    /// dialed before. Both paths require we've seen the peer on our
1662    /// gossipsub mesh or dialed them before — bare-ID dialing on a
1663    /// cold mesh is fundamentally impossible without a routing layer
1664    /// huddle deliberately doesn't run (DHT, central directory). For
1665    /// cross-internet first contact, paste an invite link instead.
1666    pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1667        let trimmed = input.trim();
1668        if trimmed.is_empty() {
1669            return Err(HuddleError::Other("input is empty".into()));
1670        }
1671        let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1672            fp
1673        } else {
1674            let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1675            if matches.is_empty() {
1676                return Err(HuddleError::Other(format!(
1677                    "no peer named `{}` known yet — paste their invite link instead",
1678                    trimmed
1679                )));
1680            }
1681            if matches.len() > 1 {
1682                return Err(HuddleError::Other(format!(
1683                    "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1684                    trimmed,
1685                    matches.len()
1686                )));
1687            }
1688            matches.into_iter().next().unwrap()
1689        };
1690        if target_fp == self.identity.fingerprint() {
1691            return Err(HuddleError::Other("that's your own ID".into()));
1692        }
1693        let candidates = self.resolve_dial_addrs(&target_fp);
1694        if candidates.is_empty() {
1695            return Err(HuddleError::Other(format!(
1696                "haven't seen `{}` on the network yet — ask them for an invite link",
1697                short_fp_for_msg(&target_fp)
1698            )));
1699        }
1700        // Pre-record every candidate so the lobby's known-peers panel
1701        // surfaces them even before the post-identify handler lands.
1702        // We bind each address to the resolved fingerprint so the
1703        // post-identify trust upgrade has the same fp to confirm.
1704        let now = now_unix();
1705        for addr in &candidates {
1706            let _ = repo::upsert_known_peer(
1707                &self.db,
1708                &KnownPeer {
1709                    address: addr.clone(),
1710                    label: None,
1711                    last_connected_at: None,
1712                    last_attempt_at: Some(now),
1713                    created_at: now,
1714                    fingerprint: Some(target_fp.clone()),
1715                    trusted: false,
1716                },
1717            );
1718        }
1719        // Parse to Multiaddrs, drop any that don't lex. Empty after
1720        // parsing would mean every candidate is malformed — unlikely
1721        // but defended-against.
1722        let multiaddrs: Vec<Multiaddr> = candidates
1723            .iter()
1724            .filter_map(|s| s.parse::<Multiaddr>().ok())
1725            .collect();
1726        if multiaddrs.is_empty() {
1727            return Err(HuddleError::Other(
1728                "every known address for that peer is malformed".into(),
1729            ));
1730        }
1731        let _ = self.app_event_tx.send(AppEvent::Dialing {
1732            address: candidates[0].clone(),
1733        });
1734        info!(
1735            target_fp = %target_fp,
1736            n = multiaddrs.len(),
1737            "dialing peer with {} candidate addresses",
1738            multiaddrs.len()
1739        );
1740        // huddle 0.7.7: user-initiated dial — register every candidate
1741        // canonical address so whichever wins the libp2p race triggers
1742        // the post-identify auto-DM. Reset & insert under one lock.
1743        {
1744            let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
1745            for m in &multiaddrs {
1746                pending.insert(m.to_string());
1747            }
1748        }
1749        self.network.dial_addresses(multiaddrs).await;
1750        Ok(())
1751    }
1752
1753    /// huddle 0.5.2: every dialable multiaddr we know for `fingerprint`,
1754    /// sorted by transport preference so libp2p's parallel dialer races
1755    /// the cheapest paths first. Order: RFC1918 LAN ip4 → loopback (for
1756    /// tests) → public ip4 → ip6 / dns → relay-hopped (`/p2p-circuit`)
1757    /// last. libp2p races them concurrently anyway — sorting just
1758    /// gives the first-attempted slot to the address most likely to
1759    /// win on a tie.
1760    fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1761        let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1762        for room in self.discovered_rooms.lock().unwrap().values() {
1763            if room.creator_fingerprint == fingerprint {
1764                for addr in &room.host_addrs {
1765                    set.insert(addr.clone());
1766                }
1767            }
1768        }
1769        if let Ok(known) = repo::list_known_peers(&self.db) {
1770            for peer in known {
1771                if peer.fingerprint.as_deref() == Some(fingerprint) {
1772                    set.insert(peer.address);
1773                }
1774            }
1775        }
1776        let mut v: Vec<String> = set.into_iter().collect();
1777        v.sort_by_key(|a| address_preference(a));
1778        v
1779    }
1780
1781    pub async fn dial(&self, input: &str) -> Result<()> {
1782        let multiaddr = parse_dial_address(input)?;
1783        let canonical = multiaddr.to_string();
1784        // huddle 0.7.7: user-initiated entry point. Register the address
1785        // so the post-Identify handler auto-opens a DM with the peer.
1786        // The auto-reconnector goes through `dial_internal` instead and
1787        // therefore does NOT trigger an auto-DM on every startup.
1788        self.pending_auto_dm_addrs
1789            .lock()
1790            .unwrap()
1791            .insert(canonical.clone());
1792        self.dial_internal(canonical, multiaddr).await
1793    }
1794
1795    /// huddle 0.7.7: shared dial body used by the public `dial()` entry
1796    /// point and by internal reconnect paths. The two callers differ
1797    /// only in whether they register the address for auto-DM-after-
1798    /// identify; internal paths (startup reconnector, host-addr
1799    /// opportunistic dial) do not.
1800    pub(crate) async fn dial_internal(
1801        &self,
1802        canonical: String,
1803        multiaddr: Multiaddr,
1804    ) -> Result<()> {
1805        info!(%canonical, "dialing");
1806        repo::upsert_known_peer(
1807            &self.db,
1808            &KnownPeer {
1809                address: canonical.clone(),
1810                label: None,
1811                last_connected_at: None,
1812                last_attempt_at: Some(now_unix()),
1813                created_at: now_unix(),
1814                // Fingerprint isn't known until Identify lands after the
1815                // dial completes; the connection-success handler upserts
1816                // again with the fingerprint and trusted=true.
1817                fingerprint: None,
1818                trusted: false,
1819            },
1820        )?;
1821
1822        let _ = self.app_event_tx.send(AppEvent::Dialing {
1823            address: canonical.clone(),
1824        });
1825        self.network.dial(multiaddr).await;
1826        Ok(())
1827    }
1828
1829    /// Phase D follow-up: snapshot of the NAT reachability state.
1830    /// Returns the addresses AutoNAT has confirmed as externally
1831    /// reachable in this session. The lobby renders an emoji badge
1832    /// from this — non-empty ⇒ 'reachable', empty ⇒ 'LAN only'.
1833    pub fn nat_reachable_addrs(&self) -> Vec<String> {
1834        self.nat_reachable_addrs
1835            .lock()
1836            .unwrap()
1837            .iter()
1838            .cloned()
1839            .collect()
1840    }
1841
1842    /// Phase D follow-up: addresses suitable for putting on the wire
1843    /// so other peers can dial us. Union of:
1844    ///   - AutoNAT-confirmed external addresses (direct internet)
1845    ///   - active `/p2p-circuit` reservations on configured relays
1846    /// Capped at 4 entries to keep room announcements small.
1847    /// Relay-circuit addresses are listed first (they're more likely
1848    /// to work for NAT'd peers).
1849    pub fn dialable_addrs(&self) -> Vec<String> {
1850        let mut out: Vec<String> = self
1851            .relay_circuit_addrs
1852            .lock()
1853            .unwrap()
1854            .iter()
1855            .cloned()
1856            .collect();
1857        for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1858            if !out.contains(a) {
1859                out.push(a.clone());
1860            }
1861        }
1862        out.truncate(4);
1863        out
1864    }
1865
1866    /// Phase C follow-up: dial a peer whose multiaddr came from an
1867    /// invite link claiming `claimed_fp`. Behaves identically to
1868    /// `dial`, but additionally stashes `(canonical_addr → claimed_fp)`
1869    /// in `pending_invite_dials` so the `PeerIdentified` handler can
1870    /// assert the cryptographic fp matches the human-display one in
1871    /// the invite. Mismatch ⇒ disconnect + `InviteFingerprintMismatch`
1872    /// event.
1873    ///
1874    /// libp2p's `/p2p/<peer-id>` segment already enforces this at the
1875    /// transport level (and our invite generator always includes it),
1876    /// so this is defense in depth — but it makes the assert explicit
1877    /// rather than relying on a structural side effect.
1878    pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1879        let multiaddr = parse_dial_address(address)?;
1880        let canonical = multiaddr.to_string();
1881        self.pending_invite_dials
1882            .lock()
1883            .unwrap()
1884            .insert(canonical.clone(), claimed_fp.to_string());
1885        // Re-use the standard dial path so KnownPeer rows + status
1886        // events look identical to a plain dial.
1887        self.dial(address).await
1888    }
1889
1890    /// huddle 0.7.12: pre-seed an invite's room so an immediate join
1891    /// works without waiting for the host's gossip announcement to
1892    /// arrive over the just-opened connection. Decodes the (optional)
1893    /// salt into `ROOM_SALT_CACHE` and inserts a `discovered_rooms`
1894    /// entry, so `join_room` can resolve the room's metadata AND derive
1895    /// the passphrase key the moment the user submits.
1896    ///
1897    /// Pre-0.7.12 the invite's `salt_b64` + room metadata were decoded
1898    /// and then thrown away; `join_room` could only learn the room from
1899    /// a live announcement, so submitting the passphrase before that
1900    /// announcement landed errored "room {id} not found". The invite
1901    /// already carries everything required — we just plumb it through.
1902    pub fn seed_invite_room(&self, room: &crate::invite::InviteRoom) {
1903        if let Some(salt) = room.salt_b64.as_deref().and_then(|b| B64.decode(b).ok()) {
1904            remember_room_salt(&room.id, salt);
1905        }
1906        let discovered = DiscoveredRoom {
1907            room_id: room.id.clone(),
1908            name: room.name.clone(),
1909            encrypted: room.encrypted,
1910            member_count: 0,
1911            creator_fingerprint: room.creator_fingerprint.clone(),
1912            last_seen: now_unix(),
1913            restorable: false,
1914            host_addrs: Vec::new(),
1915            // Invites are group-scoped — DMs are 1-1 and never invited.
1916            kind: RoomKind::Group,
1917        };
1918        self.discovered_rooms
1919            .lock()
1920            .unwrap()
1921            .insert(room.id.clone(), discovered);
1922    }
1923
1924    pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1925        let connected = self.connected_dial_addrs.lock().unwrap().clone();
1926        let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1927        stored
1928            .into_iter()
1929            .map(|p| {
1930                let connected_peer = connected.get(&p.address).copied();
1931                KnownPeerStatus {
1932                    address: p.address,
1933                    label: p.label,
1934                    last_connected_at: p.last_connected_at,
1935                    connected_peer_id: connected_peer,
1936                    fingerprint: p.fingerprint,
1937                }
1938            })
1939            .collect()
1940    }
1941
1942    pub async fn forget_peer(&self, address: &str) -> Result<()> {
1943        repo::forget_known_peer(&self.db, address)?;
1944        self.connected_dial_addrs.lock().unwrap().remove(address);
1945        Ok(())
1946    }
1947
1948    // -------------------------------------------------------------------
1949    // huddle 1.0: Contacts — the durable, fingerprint-keyed address book
1950    // -------------------------------------------------------------------
1951
1952    /// Record (or refresh) a contact. Idempotent; safe to call from every
1953    /// relationship path (start_direct, trust_inbound, accepted requests).
1954    /// Caches the partner's Ed25519 pubkey when known and the canonical DM
1955    /// room id. Never adds ourselves.
1956    pub fn add_contact(&self, fingerprint: &str, source: &str) -> Result<()> {
1957        let our_fp = self.identity.fingerprint();
1958        if fingerprint == our_fp || fingerprint.is_empty() {
1959            return Ok(());
1960        }
1961        let dm_room_id = canonical_dm_room_id(our_fp, fingerprint);
1962        // huddle 1.2: route this contact's DM relay traffic by fingerprint
1963        // (direct delivery), not by room-membership fan-out — so DMs reach
1964        // them reliably even before both sides have subscribed the DM room.
1965        self.network
1966            .register_dm(dm_room_id.clone(), fingerprint.to_string());
1967        let pubkey = repo::lookup_peer_ed25519_pubkey(&self.db, fingerprint)
1968            .ok()
1969            .flatten();
1970        let now = now_unix();
1971        repo::upsert_contact(
1972            &self.db,
1973            &repo::Contact {
1974                fingerprint: fingerprint.to_string(),
1975                alias: None,
1976                ed25519_pubkey: pubkey,
1977                dm_room_id: Some(dm_room_id),
1978                source: source.to_string(),
1979                note: None,
1980                added_at: now,
1981                last_seen: Some(now),
1982            },
1983        )
1984    }
1985
1986    pub fn set_contact_alias(&self, fingerprint: &str, alias: Option<&str>) -> Result<()> {
1987        repo::set_contact_alias(&self.db, fingerprint, alias)
1988    }
1989
1990    pub fn remove_contact(&self, fingerprint: &str) -> Result<()> {
1991        repo::delete_contact(&self.db, fingerprint)
1992    }
1993
1994    pub fn is_contact(&self, fingerprint: &str) -> bool {
1995        repo::is_contact(&self.db, fingerprint).unwrap_or(false)
1996    }
1997
1998    /// The unified Contacts list: the durable address book joined with
1999    /// derived username / verified / trusted / reachability so the UI never
2000    /// has to stitch four tables together.
2001    pub fn list_contacts(&self) -> Vec<ContactView> {
2002        let our_fp = self.identity.fingerprint().to_string();
2003        let verified: HashSet<String> = repo::list_verified_peers(&self.db)
2004            .unwrap_or_default()
2005            .into_iter()
2006            .collect();
2007        // A peer is "LAN-connected" when any known_peer row bearing its
2008        // fingerprint currently maps to a live libp2p connection.
2009        let connected = self.connected_dial_addrs.lock().unwrap().clone();
2010        let lan_fps: HashSet<String> = repo::list_known_peers(&self.db)
2011            .unwrap_or_default()
2012            .into_iter()
2013            .filter(|p| connected.contains_key(&p.address))
2014            .filter_map(|p| p.fingerprint)
2015            .collect();
2016        let relay_up = self.server_connected();
2017        repo::list_contacts(&self.db)
2018            .unwrap_or_default()
2019            .into_iter()
2020            .filter(|c| c.fingerprint != our_fp)
2021            .map(|c| {
2022                let lan_connected = lan_fps.contains(&c.fingerprint);
2023                ContactView {
2024                    dm_room_id: c
2025                        .dm_room_id
2026                        .clone()
2027                        .unwrap_or_else(|| canonical_dm_room_id(&our_fp, &c.fingerprint)),
2028                    username: repo::get_peer_username(&self.db, &c.fingerprint).unwrap_or(None),
2029                    verified: verified.contains(&c.fingerprint),
2030                    trusted: repo::is_fingerprint_trusted(&self.db, &c.fingerprint)
2031                        .unwrap_or(false),
2032                    reachable: lan_connected || relay_up,
2033                    lan_connected,
2034                    fingerprint: c.fingerprint,
2035                    alias: c.alias,
2036                    source: c.source,
2037                    added_at: c.added_at,
2038                    last_seen: c.last_seen,
2039                }
2040            })
2041            .collect()
2042    }
2043
2044    // -------------------------------------------------------------------
2045    // huddle 1.0: contact requests over the relay inbox (Phase 1)
2046    // -------------------------------------------------------------------
2047
2048    /// "Add by HD-ID" that works over the internet: publish a signed
2049    /// `ContactRequest` to the target's relay inbox. The target picks it up
2050    /// (live, or from the relay's offline mailbox) and surfaces it as a
2051    /// pending request to accept/decline. On the LAN, the same publish also
2052    /// rides gossipsub. Refuses self.
2053    pub async fn send_contact_request(
2054        &self,
2055        target_fingerprint: &str,
2056        note: Option<&str>,
2057    ) -> Result<()> {
2058        let our_fp = self.identity.fingerprint().to_string();
2059        if target_fingerprint == our_fp {
2060            return Err(HuddleError::Other("that's your own ID".into()));
2061        }
2062        // Record the target so their accept-echo is recognized as mutual (see
2063        // the ContactRequest receive arm) instead of re-prompting us.
2064        let _ = self.add_contact(target_fingerprint, "request-sent");
2065        let msg = RoomMessage::ContactRequest {
2066            requester_fingerprint: our_fp,
2067            display_name: repo::get_display_name(&self.db).unwrap_or(None),
2068            note: note.map(|s| s.to_string()),
2069            sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
2070        };
2071        let env = crate::crypto::sign_message(&self.identity, &msg)?;
2072        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2073        let inbox = crate::network::protocol::inbox_room_id(target_fingerprint);
2074        // huddle 1.2: deliver the request STRAIGHT to the target's fingerprint
2075        // over the relay (live, or queued in their mailbox if offline), tagged
2076        // with their inbox id so their client files it as a contact request.
2077        // This no longer depends on the target having an active inbox
2078        // subscription on the relay, and also rides libp2p gossipsub on the
2079        // inbox topic for LAN delivery.
2080        self.network
2081            .publish_direct(target_fingerprint.to_string(), inbox, bytes)
2082            .await;
2083        Ok(())
2084    }
2085
2086    /// Inbound contact requests awaiting an accept/decline decision.
2087    pub fn list_pending_contact_requests(&self) -> Vec<repo::PendingContactRequest> {
2088        repo::list_pending_contact_requests(&self.db).unwrap_or_default()
2089    }
2090
2091    /// Accept a pending contact request: record the contact and open the DM
2092    /// (idempotent on the canonical room id). Both sides converge — the
2093    /// requester opens the same DM when our resulting `MemberAnnounce` /
2094    /// announcement reaches them. Removes the pending row regardless.
2095    pub async fn accept_contact_request(&self, fingerprint: &str) -> Result<()> {
2096        repo::delete_pending_contact_request(&self.db, fingerprint)?;
2097        self.add_contact(fingerprint, "request")?;
2098        // start_direct subscribes the canonical DM room + broadcasts our
2099        // MemberAnnounce, making the DM live on our side.
2100        self.start_direct(fingerprint).await?;
2101        // Echo a request back to the requester's inbox so they converge: the
2102        // requester already has us in their address book (they initiated), so
2103        // their ContactRequest receive arm treats this as mutual and
2104        // subscribes the same DM room — essential for the relay path, where
2105        // our MemberAnnounce can't reach them until they're a room member.
2106        let _ = self.send_contact_request(fingerprint, None).await;
2107        Ok(())
2108    }
2109
2110    /// huddle 1.2.1: ask the relay to mint a short-lived **connect code** bound
2111    /// to our identity, so a peer can add/DM us by typing the code instead of
2112    /// our full HD-ID. The code (and its expiry) arrive asynchronously as
2113    /// `AppEvent::ConnectCodeCreated`. Errors immediately if the relay isn't
2114    /// connected (codes are a relay feature — there's no one to mint them).
2115    pub fn create_connect_code(&self) -> Result<()> {
2116        if !self.network.create_connect_token() {
2117            return Err(HuddleError::Network(
2118                "not connected to the relay — can't create a connect code".into(),
2119            ));
2120        }
2121        Ok(())
2122    }
2123
2124    /// huddle 1.2.1: redeem a connect code someone shared. The relay resolves
2125    /// it to their identity and we send them a contact request (which opens a
2126    /// DM once they accept). Progress arrives as `AppEvent::ConnectCodeRedeemed`
2127    /// / `ConnectCodeFailed`. Errors immediately for a malformed code or when
2128    /// the relay isn't connected.
2129    pub fn redeem_connect_code(&self, code: &str) -> Result<()> {
2130        let norm = normalize_connect_code(code)
2131            .ok_or_else(|| HuddleError::Other("that doesn't look like a connect code".into()))?;
2132        if !self.network.redeem_connect_token(&norm) {
2133            return Err(HuddleError::Network(
2134                "not connected to the relay — can't redeem a connect code".into(),
2135            ));
2136        }
2137        Ok(())
2138    }
2139
2140    /// huddle 1.2.1: the relay resolved a connect code we redeemed. Validate the
2141    /// resolution, then send the owner a contact request (which opens a DM when
2142    /// they accept). Emits `ConnectCodeRedeemed` on success, `ConnectCodeFailed`
2143    /// otherwise.
2144    async fn on_connect_code_resolved(
2145        &self,
2146        fingerprint: Option<String>,
2147        pubkey_b64: Option<String>,
2148    ) {
2149        let our_fp = self.identity.fingerprint().to_string();
2150        let fp = match fingerprint {
2151            Some(fp) if !fp.is_empty() => fp,
2152            _ => {
2153                let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2154                    reason: "invalid or expired connect code".into(),
2155                });
2156                return;
2157            }
2158        };
2159        if fp == our_fp {
2160            let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2161                reason: "that's your own connect code".into(),
2162            });
2163            return;
2164        }
2165        // Integrity check: if the relay also returned the owner's pubkey, it
2166        // MUST hash to the fingerprint it claims — else the mapping is bogus
2167        // (a buggy or hostile relay). The real identity proof still comes from
2168        // the owner's signed reply; this just rejects an obviously-wrong map.
2169        if let Some(pk_b64) = pubkey_b64.as_deref() {
2170            if let Some(pk) = B64
2171                .decode(pk_b64)
2172                .ok()
2173                .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
2174            {
2175                if crate::identity::compute_fingerprint(&pk) != fp {
2176                    let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2177                        reason: "connect code resolved to a mismatched identity".into(),
2178                    });
2179                    return;
2180                }
2181            }
2182        }
2183        match self.send_contact_request(&fp, None).await {
2184            Ok(()) => {
2185                let _ = self
2186                    .app_event_tx
2187                    .send(AppEvent::ConnectCodeRedeemed { fingerprint: fp });
2188            }
2189            Err(e) => {
2190                let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2191                    reason: format!("couldn't send the request: {e}"),
2192                });
2193            }
2194        }
2195    }
2196
2197    /// Decline a pending contact request. `block` also adds the requester to
2198    /// the persistent blocklist so they can't re-request.
2199    pub fn reject_contact_request(&self, fingerprint: &str, block: bool) -> Result<()> {
2200        repo::delete_pending_contact_request(&self.db, fingerprint)?;
2201        if block {
2202            repo::block_peer(&self.db, fingerprint, now_unix())?;
2203        }
2204        Ok(())
2205    }
2206
2207    /// Re-dial a stored address — used by the lobby's "reconnect" action.
2208    pub async fn redial(&self, address: &str) -> Result<()> {
2209        self.dial(address).await
2210    }
2211
2212    /// Phase A: user pressed Accept on the inbound-dial modal. Promotes
2213    /// the peer to the gossipsub mesh. Does NOT mark them trusted —
2214    /// that's `trust_inbound`, the explicit "remember and bypass next
2215    /// time" path.
2216    pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
2217        self.network.accept_inbound(peer_id).await;
2218        self.connected_dial_addrs
2219            .lock()
2220            .unwrap()
2221            .insert(address.to_string(), peer_id);
2222    }
2223
2224    /// Phase A: user pressed Reject on the inbound-dial modal. Disconnects
2225    /// the peer, adds them to the persistent blocklist, and ensures every
2226    /// subsequent connection attempt from this fingerprint is auto-
2227    /// dropped without re-prompting.
2228    pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
2229        self.network.reject_inbound(peer_id).await;
2230        repo::block_peer(&self.db, fingerprint, now_unix())?;
2231        Ok(())
2232    }
2233
2234    /// Phase A: user pressed Trust+Accept — accept the connection AND
2235    /// remember the peer so subsequent connections bypass the modal.
2236    pub async fn trust_inbound(
2237        &self,
2238        peer_id: PeerId,
2239        fingerprint: &str,
2240        address: &str,
2241    ) -> Result<()> {
2242        self.network.accept_inbound(peer_id).await;
2243        self.connected_dial_addrs
2244            .lock()
2245            .unwrap()
2246            .insert(address.to_string(), peer_id);
2247        // Persist the row with trusted=true so future inbound from
2248        // this fingerprint short-circuits the modal in
2249        // `process_network_event`'s InboundDial handler.
2250        repo::upsert_known_peer(
2251            &self.db,
2252            &KnownPeer {
2253                address: address.to_string(),
2254                label: None,
2255                last_connected_at: Some(now_unix()),
2256                last_attempt_at: Some(now_unix()),
2257                created_at: now_unix(),
2258                fingerprint: Some(fingerprint.to_string()),
2259                trusted: true,
2260            },
2261        )?;
2262        // huddle 1.0: trusting a peer makes them a contact.
2263        let _ = self.add_contact(fingerprint, "dial");
2264        Ok(())
2265    }
2266
2267    // =========================================================================
2268    // huddle 0.7.7: pending friend requests (3-day TTL)
2269    // =========================================================================
2270
2271    /// Snapshot of every inbound dial we've spilled to disk but haven't
2272    /// yet accepted or rejected. The People pane renders this as its
2273    /// own section ("Pending requests (N)").
2274    pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
2275        repo::list_pending_friend_requests(&self.db).unwrap_or_default()
2276    }
2277
2278    /// Persist an inbound request that the user didn't act on within the
2279    /// modal window. Called from the TUI's idle-timeout sweep; the live
2280    /// libp2p connection is also closed by the same path (the request
2281    /// is effectively rejected *for now* — accept later from People
2282    /// pane will re-dial the stored address).
2283    pub fn spill_pending_friend_request(
2284        &self,
2285        peer_id: PeerId,
2286        fingerprint: &str,
2287        address: &str,
2288    ) -> Result<()> {
2289        repo::upsert_pending_friend_request(
2290            &self.db,
2291            &repo::PendingFriendRequest {
2292                fingerprint: fingerprint.to_string(),
2293                address: address.to_string(),
2294                peer_id: peer_id.to_string(),
2295                received_at: now_unix(),
2296            },
2297        )?;
2298        Ok(())
2299    }
2300
2301    /// User pressed Accept on a row in the Pending requests list. The
2302    /// original libp2p connection is long gone (we closed it on
2303    /// timeout); re-dial the stored address and mark the peer trusted
2304    /// so the post-Identify handler short-circuits the modal. The
2305    /// row is removed regardless of dial success — a failed dial is
2306    /// still a positive intent we don't want to keep re-prompting on.
2307    pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
2308        let mut chosen_addr: Option<String> = None;
2309        for req in self.list_pending_friend_requests() {
2310            if req.fingerprint == fingerprint {
2311                chosen_addr = Some(req.address);
2312                break;
2313            }
2314        }
2315        repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
2316        // huddle 1.0: accepting a friend request makes them a contact.
2317        let _ = self.add_contact(fingerprint, "request");
2318        if let Some(addr) = chosen_addr {
2319            // Pre-mark trusted so the upcoming Identify handler skips
2320            // the inbound-dial modal. Matches the semantics of
2321            // `trust_inbound` without needing a live PeerId.
2322            repo::upsert_known_peer(
2323                &self.db,
2324                &KnownPeer {
2325                    address: addr.clone(),
2326                    label: None,
2327                    last_connected_at: None,
2328                    last_attempt_at: Some(now_unix()),
2329                    created_at: now_unix(),
2330                    fingerprint: Some(fingerprint.to_string()),
2331                    trusted: true,
2332                },
2333            )?;
2334            // User-initiated — register for auto-DM on connect.
2335            self.dial(&addr).await?;
2336        }
2337        Ok(())
2338    }
2339
2340    /// User pressed Reject on a row in the Pending requests list.
2341    /// Mirrors `reject_inbound` semantics: delete the pending row(s)
2342    /// AND block the fingerprint so any future dial from this peer is
2343    /// auto-dropped without re-prompting.
2344    pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
2345        repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
2346        repo::block_peer(&self.db, fingerprint, now_unix())?;
2347        Ok(())
2348    }
2349
2350    /// huddle 0.7.7: close a live libp2p connection without blocking the
2351    /// peer. Used by the TUI's 15s InboundDial timeout — we need to
2352    /// drop the dangling socket, but blocking the peer would
2353    /// contradict "save the request for 3 days, let the user decide
2354    /// later." `reject_inbound` is the right call when the user
2355    /// *explicitly* clicks Reject.
2356    pub async fn disconnect_peer(&self, peer_id: PeerId) {
2357        self.network.disconnect_peer(peer_id).await;
2358    }
2359
2360    fn spawn_known_peer_reconnector(&self) {
2361        let handle = self.clone();
2362        tokio::spawn(async move {
2363            // Brief delay so our own listeners come up first.
2364            tokio::time::sleep(Duration::from_millis(500)).await;
2365            let known = repo::list_known_peers(&handle.db).unwrap_or_default();
2366            // Reconnect each peer from its own task on a staggered, jittered
2367            // delay so a long known-peer list doesn't fire a synchronized
2368            // burst of dials (and serialized DB writes) all at once.
2369            for (i, peer) in known.into_iter().enumerate() {
2370                let handle = handle.clone();
2371                tokio::spawn(async move {
2372                    // Deterministic per-address jitter de-correlates peers
2373                    // without pulling an RNG into scope.
2374                    let jitter = (peer.address.len() as u64 * 37) % 200;
2375                    tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
2376                    // huddle 0.7.7: route through `dial_internal`, NOT
2377                    // `dial`. Startup reconnects shouldn't pop a DM
2378                    // every time a known peer comes online — only
2379                    // explicit user actions trigger the auto-DM.
2380                    let multiaddr = match peer.address.parse::<Multiaddr>() {
2381                        Ok(m) => m,
2382                        Err(_) => return,
2383                    };
2384                    if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
2385                        debug!(%e, addr = %peer.address, "auto-reconnect failed");
2386                    }
2387                });
2388            }
2389        });
2390    }
2391
2392    // -------------------------------------------------------------------
2393    // Internal helpers
2394    // -------------------------------------------------------------------
2395
2396    fn load_or_create_identity(db: &Db) -> Result<Identity> {
2397        if let Some(stored) = repo::load_identity(db)? {
2398            let mut bytes = [0u8; 32];
2399            bytes.copy_from_slice(&stored.ed25519_secret);
2400            Identity::from_secret_bytes(bytes)
2401        } else {
2402            let id = Identity::generate()?;
2403            repo::save_identity(db, &id.secret_bytes(), now_unix())?;
2404            Ok(id)
2405        }
2406    }
2407
2408    fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
2409        self.active_rooms
2410            .lock()
2411            .unwrap()
2412            .get(room_id)
2413            .and_then(|r| r.info.passphrase_salt.clone())
2414            .or_else(|| {
2415                // Try the cached announcement salt
2416                ROOM_SALT_CACHE
2417                    .lock()
2418                    .unwrap()
2419                    .get(room_id)
2420                    .cloned()
2421            })
2422    }
2423
2424    async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
2425        let owner_fingerprints =
2426            repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
2427        let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
2428        let host_addrs = self.dialable_addrs();
2429        let ann = RoomAnnouncement {
2430            room_id: info.id.clone(),
2431            name: info.name.clone(),
2432            encrypted: info.encrypted,
2433            passphrase_salt: info.passphrase_salt.clone(),
2434            member_count,
2435            creator_fingerprint: info.creator_fingerprint.clone(),
2436            announced_at: now_unix(),
2437            owner_fingerprints,
2438            verified_only,
2439            host_addrs,
2440            kind: info.kind,
2441        };
2442        self.network.announce_room(ann).await;
2443    }
2444
2445    async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
2446        let our_fp = self.identity.fingerprint().to_string();
2447        let wrapped = {
2448            let mut rooms = self.active_rooms.lock().unwrap();
2449            let room = rooms
2450                .get_mut(room_id)
2451                .ok_or_else(|| HuddleError::Other("not in room".into()))?;
2452            if room.info.encrypted {
2453                let crypto = room.crypto.as_mut().unwrap();
2454                let session_key = crypto.our_session_key_b64();
2455                match room.passphrase_key.as_ref() {
2456                    Some(passphrase_key) => {
2457                        Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
2458                    }
2459                    None if room.info.kind == RoomKind::Direct => {
2460                        // huddle 0.7.1: DM-specific path — partner's
2461                        // pubkey hasn't been observed yet, so we can't
2462                        // derive the ECDH key. Send announce without
2463                        // a wrapped key — it carries our Ed25519
2464                        // pubkey, which lets the partner derive the
2465                        // key on their side. They'll respond with
2466                        // their own wrapped key in a follow-up
2467                        // announce; once we receive it we re-broadcast
2468                        // ours with the wrap filled in.
2469                        None
2470                    }
2471                    None => {
2472                        return Err(HuddleError::Session("missing passphrase key".into()));
2473                    }
2474                }
2475            } else {
2476                None
2477            }
2478        };
2479        let display_name = repo::get_display_name(&self.db).unwrap_or(None);
2480        let msg = RoomMessage::MemberAnnounce {
2481            sender_fingerprint: our_fp,
2482            wrapped_session_key: wrapped,
2483            display_name,
2484            sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
2485        };
2486        // huddle 0.7.11: MemberAnnounce is now signed end-to-end. The
2487        // envelope's Ed25519 pubkey is the canonical TOFU pin for this
2488        // fingerprint; the inner `sender_ed25519_pubkey` field stays
2489        // present for back-compat parsing but is no longer authoritative.
2490        let env = crate::crypto::sign_message(&self.identity, &msg)?;
2491        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2492        self.network
2493            .publish_room_message(room_id.to_string(), bytes)
2494            .await;
2495        Ok(())
2496    }
2497
2498    fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
2499        let handle = self.clone();
2500        tokio::spawn(async move {
2501            while let Some(event) = net_rx.recv().await {
2502                handle.process_network_event(event).await;
2503            }
2504            info!("event processor stopped");
2505        });
2506    }
2507
2508    /// huddle 0.8/1.0: maintain a connection to the relay backend for the
2509    /// life of the process. Reconnects with capped exponential backoff. Each
2510    /// attempt tries the transport "doors" in `order` (onion first, clearnet
2511    /// last, or a single pinned door) until one connects — so a censored user
2512    /// whose Tor is blocked transparently falls through to a clearnet door.
2513    /// While connected, the [`NetworkHandle`] mirrors outgoing room traffic
2514    /// to it (see `attach_server`), and incoming server messages are funneled
2515    /// into the *same* `RoomMessageReceived` handler as gossipsub — so a
2516    /// message arriving via the relay is decoded, verified, and decrypted by
2517    /// exactly the same code path. The live door is recorded in
2518    /// `active_transport` for the UI/CLI.
2519    /// huddle 1.2: every room id whose membership must be asserted on the
2520    /// relay — active rooms, rooms parked as `restorable` (encrypted groups /
2521    /// keyless DMs awaiting a passphrase or the partner's pubkey), and the aux
2522    /// subscriptions (our own contact inbox). Used both to build the Hello
2523    /// room set and to re-subscribe after each (re)connect, so the relay knows
2524    /// we belong to a room even before we can decrypt it — otherwise its
2525    /// fan-out skips us and group messages silently never arrive.
2526    fn relay_membership_ids(&self) -> Vec<String> {
2527        let mut set: HashSet<String> =
2528            self.active_rooms.lock().unwrap().keys().cloned().collect();
2529        set.extend(self.restorable_rooms.lock().unwrap().keys().cloned());
2530        set.extend(self.aux_subscriptions.lock().unwrap().iter().cloned());
2531        set.into_iter().collect()
2532    }
2533
2534    fn spawn_server_connection(&self, order: Vec<TransportId>) {
2535        let handle = self.clone();
2536        tokio::spawn(async move {
2537            let mut backoff = 1u64;
2538            loop {
2539                // huddle 1.0: the Hello room set is every active chat room
2540                // PLUS our aux subscriptions (the contact inbox), so the relay
2541                // re-registers inbox membership on every reconnect and flushes
2542                // any queued contact requests.
2543                let rooms: Vec<String> = handle.relay_membership_ids();
2544
2545                // Try each door in order until one connects. Unavailable
2546                // doors (no URL / wrong build) are skipped.
2547                let mut connected: Option<(
2548                    ServerClient,
2549                    tokio::sync::mpsc::UnboundedReceiver<ServerEvent>,
2550                    TransportId,
2551                )> = None;
2552                for id in &order {
2553                    let (url, dial) = match handle
2554                        .transport_profiles
2555                        .iter()
2556                        .find(|p| p.id == *id)
2557                    {
2558                        Some(p) if p.available() => {
2559                            (p.url.clone().unwrap(), p.dial.clone().unwrap())
2560                        }
2561                        _ => continue,
2562                    };
2563                    match ServerClient::connect(
2564                        &url,
2565                        &dial,
2566                        handle.identity.clone(),
2567                        rooms.clone(),
2568                    )
2569                    .await
2570                    {
2571                        Ok((client, rx)) => {
2572                            info!(%url, transport = id.as_str(), "connected to relay");
2573                            connected = Some((client, rx, *id));
2574                            break;
2575                        }
2576                        Err(e) => {
2577                            debug!(error = %e, transport = id.as_str(), %url, "relay door failed; trying next");
2578                        }
2579                    }
2580                }
2581
2582                if let Some((client, mut rx, id)) = connected {
2583                    backoff = 1;
2584                    handle.network.attach_server(client);
2585                    *handle.active_transport.lock().unwrap() = Some(id);
2586                    // huddle 1.2: re-assert membership for every active room
2587                    // over the freshly attached connection. Hello carried the
2588                    // room snapshot taken before we connected, so a room
2589                    // created/joined during the connect-handshake window would
2590                    // otherwise stay unknown to the relay until the next
2591                    // reconnect — silently breaking group fan-out for it. The
2592                    // relay's add_membership is idempotent, so re-subscribing is
2593                    // free. (DM rooms route by fingerprint and don't depend on
2594                    // this, but re-subscribing them is harmless.)
2595                    for rid in handle.relay_membership_ids() {
2596                        handle.network.subscribe_room(rid).await;
2597                    }
2598                    while let Some(ev) = rx.recv().await {
2599                        match ev {
2600                            ServerEvent::Message { room, payload, .. } => {
2601                                handle
2602                                    .process_network_event(NetworkEvent::RoomMessageReceived {
2603                                        room_id: room,
2604                                        payload,
2605                                        from_peer: PeerId::random(),
2606                                    })
2607                                    .await;
2608                            }
2609                            ServerEvent::Ready | ServerEvent::Sent { .. } => {}
2610                            ServerEvent::ConnectToken { token, ttl_secs } => {
2611                                // huddle 1.2.1: relay minted our connect code.
2612                                let expires_at = now_unix() + ttl_secs as i64;
2613                                let _ = handle.app_event_tx.send(AppEvent::ConnectCodeCreated {
2614                                    code: token,
2615                                    expires_at,
2616                                });
2617                            }
2618                            ServerEvent::ConnectTokenResolved {
2619                                fingerprint,
2620                                pubkey_b64,
2621                            } => {
2622                                handle.on_connect_code_resolved(fingerprint, pubkey_b64).await;
2623                            }
2624                            ServerEvent::Disconnected => break,
2625                        }
2626                    }
2627                    handle.network.detach_server();
2628                    *handle.active_transport.lock().unwrap() = None;
2629                    warn!("relay connection closed; reconnecting");
2630                } else {
2631                    warn!("all relay doors failed; will retry");
2632                }
2633                tokio::time::sleep(Duration::from_secs(backoff)).await;
2634                backoff = (backoff * 2).min(30);
2635            }
2636        });
2637    }
2638
2639    fn spawn_announcement_ticker(&self) {
2640        let handle = self.clone();
2641        tokio::spawn(async move {
2642            let mut interval =
2643                tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
2644            interval.tick().await; // skip the immediate tick
2645            loop {
2646                interval.tick().await;
2647                let snapshot: Vec<(StoredRoom, u32)> = {
2648                    let active = handle.active_rooms.lock().unwrap();
2649                    active
2650                        .values()
2651                        .map(|r| (r.info.clone(), r.members.len() as u32))
2652                        .collect()
2653                };
2654                for (info, member_count) in snapshot {
2655                    handle.announce_room_now(&info, member_count).await;
2656                }
2657            }
2658        });
2659    }
2660
2661    fn spawn_discovered_room_pruner(&self) {
2662        let handle = self.clone();
2663        tokio::spawn(async move {
2664            let mut interval = tokio::time::interval(Duration::from_secs(10));
2665            interval.tick().await;
2666            loop {
2667                interval.tick().await;
2668                let now = now_unix();
2669                let mut to_drop = Vec::new();
2670                {
2671                    let mut map = handle.discovered_rooms.lock().unwrap();
2672                    map.retain(|id, r| {
2673                        if now - r.last_seen > DISCOVERED_TTL_SECS {
2674                            to_drop.push(id.clone());
2675                            false
2676                        } else {
2677                            true
2678                        }
2679                    });
2680                }
2681                for id in to_drop {
2682                    let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
2683                }
2684            }
2685        });
2686    }
2687
2688    async fn process_network_event(&self, event: NetworkEvent) {
2689        match event {
2690            NetworkEvent::PeerDiscovered { peer_id } => {
2691                let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
2692            }
2693            NetworkEvent::PeerExpired { peer_id } => {
2694                // Drop any tracked dial-connection entry for this peer so
2695                // the lobby's online/offline dots stay accurate. mDNS
2696                // expiry only gives us a PeerId (no fingerprint), so we
2697                // can't touch room membership here — that relies on the
2698                // explicit MemberLeave path and the discovered-room TTL.
2699                self.connected_dial_addrs
2700                    .lock()
2701                    .unwrap()
2702                    .retain(|_addr, pid| *pid != peer_id);
2703                let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
2704            }
2705            NetworkEvent::PeerDisconnected { peer_id } => {
2706                // huddle 0.7.11: relay / internet peers don't trigger
2707                // mDNS PeerExpired, so without this their entries in
2708                // connected_dial_addrs stayed forever and the lobby
2709                // showed them as "● online" indefinitely after they
2710                // dropped. Same cleanup shape as PeerExpired.
2711                self.connected_dial_addrs
2712                    .lock()
2713                    .unwrap()
2714                    .retain(|_addr, pid| *pid != peer_id);
2715                let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
2716            }
2717            // huddle 0.7.12: `RelayReservationLost` was removed —
2718            // libp2p 0.56's relay client doesn't surface a failure
2719            // variant we can listen on. Reservation loss currently
2720            // manifests as the next AutoNAT probe flipping to
2721            // "private" once the circuit drops; a future health-
2722            // check timer can re-introduce the dedicated signal.
2723            NetworkEvent::ListeningOn { address } => {
2724                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2725                    address: address.to_string(),
2726                });
2727            }
2728            NetworkEvent::RoomAnnouncementReceived(ann) => {
2729                // Cache the salt for join_room
2730                if let Some(salt) = &ann.passphrase_salt {
2731                    remember_room_salt(&ann.room_id, salt.clone());
2732                }
2733                // Phase D follow-up: opportunistically dial the
2734                // announcer's first host_addr if we're not already
2735                // connected. Skips self-announcements + rate-limits
2736                // by creator fingerprint so we don't dial-storm.
2737                let our_fp_for_dial = self.identity.fingerprint().to_string();
2738                if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
2739                    let now = now_unix();
2740                    let should_dial = {
2741                        let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
2742                        match attempts.get(&ann.creator_fingerprint).copied() {
2743                            Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
2744                            _ => {
2745                                attempts.insert(ann.creator_fingerprint.clone(), now);
2746                                true
2747                            }
2748                        }
2749                    };
2750                    if should_dial {
2751                        if let Some(first) = ann.host_addrs.first() {
2752                            info!(
2753                                announcer = %ann.creator_fingerprint,
2754                                addr = %first,
2755                                "opportunistic dial via room announcement host_addrs"
2756                            );
2757                            // huddle 0.7.7: NOT user-initiated — go
2758                            // through `dial_internal` so a passive
2759                            // announcement-driven dial doesn't pop a
2760                            // DM in the user's face.
2761                            if let Ok(multiaddr) = first.parse::<Multiaddr>() {
2762                                let canonical = multiaddr.to_string();
2763                                let _ = self.dial_internal(canonical, multiaddr).await;
2764                            }
2765                        }
2766                    }
2767                }
2768                let discovered = DiscoveredRoom {
2769                    room_id: ann.room_id.clone(),
2770                    name: ann.name.clone(),
2771                    encrypted: ann.encrypted,
2772                    member_count: ann.member_count,
2773                    creator_fingerprint: ann.creator_fingerprint.clone(),
2774                    last_seen: now_unix(),
2775                    restorable: false,
2776                    host_addrs: ann.host_addrs.clone(),
2777                    kind: ann.kind,
2778                };
2779                // If we're already in this room, cache the announcement so
2780                // others can still discover it through us, but don't emit
2781                // RoomDiscovered — it isn't "newly discovered" to us, and
2782                // emitting it spuriously re-opens the lobby join prompt.
2783                if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
2784                    self.discovered_rooms
2785                        .lock()
2786                        .unwrap()
2787                        .insert(ann.room_id.clone(), discovered);
2788                    return;
2789                }
2790                // huddle 0.7 DM-visibility filter (consumer side): a
2791                // `Direct` announcement is only valid for the two members
2792                // implied by `canonical_dm_room_id`. If we're not one of
2793                // them, silently drop — DMs never appear in third
2794                // parties' discovery caches. A malicious 0.7+ peer can
2795                // ignore this, but they'd have to subscribe to the
2796                // canonical DM topic with full knowledge of both
2797                // fingerprints, which is a stronger threat than the v1
2798                // sidebar split is trying to mitigate.
2799                if ann.kind == RoomKind::Direct {
2800                    let our_fp_for_filter = self.identity.fingerprint().to_string();
2801                    if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
2802                        != ann.room_id
2803                    {
2804                        debug!(
2805                            announcer = %ann.creator_fingerprint,
2806                            room_id = %ann.room_id,
2807                            "dropping Direct announcement: not addressed to us"
2808                        );
2809                        return;
2810                    }
2811                    // Targeted at us. Cache the discovery so the sidebar
2812                    // can show "DM from <partner>" and auto-bootstrap a
2813                    // local active room so we can receive messages
2814                    // immediately without waiting for a user action.
2815                    //
2816                    // huddle 0.7.11: drop the auto-bootstrap if the
2817                    // partner is on the persistent blocklist. Without
2818                    // this gate, a blocked peer could re-introduce
2819                    // themselves into our sidebar simply by re-announcing
2820                    // the DM topic; we'd subscribe and persist a row for
2821                    // them before any user action.
2822                    if repo::is_peer_blocked(&self.db, &ann.creator_fingerprint).unwrap_or(false)
2823                    {
2824                        debug!(
2825                            partner = %ann.creator_fingerprint,
2826                            "ignoring Direct announcement from blocked peer"
2827                        );
2828                        return;
2829                    }
2830                    self.discovered_rooms
2831                        .lock()
2832                        .unwrap()
2833                        .insert(ann.room_id.clone(), discovered.clone());
2834                    let _ = self
2835                        .app_event_tx
2836                        .send(AppEvent::RoomDiscovered(discovered.clone()));
2837                    let app = self.clone();
2838                    let partner = ann.creator_fingerprint.clone();
2839                    let rid = ann.room_id.clone();
2840                    tokio::spawn(async move {
2841                        if let Err(e) = app.start_direct(&partner).await {
2842                            debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
2843                        }
2844                    });
2845                    return;
2846                }
2847                self.discovered_rooms
2848                    .lock()
2849                    .unwrap()
2850                    .insert(ann.room_id.clone(), discovered.clone());
2851                let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
2852            }
2853            NetworkEvent::RoomMessageReceived {
2854                room_id,
2855                payload,
2856                from_peer: _,
2857            } => {
2858                // v0.3.0+: every wire message is a `WireMessage` envelope.
2859                // `Plain` carries an unsigned `RoomMessage`; `Signed` is an
2860                // app-level Ed25519 envelope that we verify before
2861                // unwrapping. A failed verify is logged and dropped — we
2862                // never dispatch unverified-but-claiming-to-be-signed
2863                // messages.
2864                let wire: WireMessage = match serde_json::from_slice(&payload) {
2865                    Ok(w) => w,
2866                    Err(e) => {
2867                        warn!(%e, "bad wire envelope");
2868                        return;
2869                    }
2870                };
2871                let (msg, verified_signer) = match wire {
2872                    WireMessage::Plain(m) => (m, None),
2873                    WireMessage::Signed(env) => {
2874                        let claimed_pubkey = env.ed25519_pubkey_b64.clone();
2875                        match crate::crypto::verify_signed(&env) {
2876                            Ok((m, fp)) => {
2877                                // Defense in depth: if we've persisted
2878                                // a pubkey for this fingerprint in this
2879                                // room before, the envelope's pubkey
2880                                // MUST match it. A different pubkey for
2881                                // the same fingerprint means identity
2882                                // drift — TOFU violation — drop.
2883                                match repo::get_member_ed25519_pubkey(
2884                                    &self.db, &room_id, &fp,
2885                                ) {
2886                                    Ok(Some(known)) if known != claimed_pubkey => {
2887                                        warn!(
2888                                            %fp, %room_id,
2889                                            "pubkey mismatch vs stored; dropping signed message"
2890                                        );
2891                                        return;
2892                                    }
2893                                    _ => {}
2894                                }
2895                                (m, Some(fp))
2896                            }
2897                            Err(e) => {
2898                                warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
2899                                return;
2900                            }
2901                        }
2902                    }
2903                };
2904                self.handle_room_message(&room_id, msg, verified_signer).await;
2905            }
2906            NetworkEvent::DialSucceeded { peer_id, address } => {
2907                let addr_s = address.to_string();
2908                self.connected_dial_addrs
2909                    .lock()
2910                    .unwrap()
2911                    .insert(addr_s.clone(), peer_id);
2912                // Fingerprint isn't known yet (Identify hasn't landed);
2913                // the PeerIdentified handler below upserts again to add
2914                // the fingerprint and flip trusted=true once it does.
2915                let _ = repo::upsert_known_peer(
2916                    &self.db,
2917                    &KnownPeer {
2918                        address: addr_s.clone(),
2919                        label: None,
2920                        last_connected_at: Some(now_unix()),
2921                        last_attempt_at: Some(now_unix()),
2922                        created_at: now_unix(),
2923                        fingerprint: None,
2924                        trusted: false,
2925                    },
2926                );
2927                let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
2928                    address: addr_s,
2929                    peer_id,
2930                });
2931            }
2932            NetworkEvent::DialFailed { address, error } => {
2933                let addr_s = address.to_string();
2934                let _ = self.app_event_tx.send(AppEvent::DialFailed {
2935                    address: addr_s,
2936                    error,
2937                });
2938            }
2939            NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2940                // For any address we user-dialed for this peer, retroactively
2941                // backfill the fingerprint and flip trusted=true. The
2942                // upsert's COALESCE preserves fingerprint once set and
2943                // its trusted-is-sticky-once-true clause means we don't
2944                // accidentally demote a row that was already trusted.
2945                let matched_addrs: Vec<String> = {
2946                    let map = self.connected_dial_addrs.lock().unwrap();
2947                    map.iter()
2948                        .filter_map(|(addr, pid)| {
2949                            if *pid == peer_id {
2950                                Some(addr.clone())
2951                            } else {
2952                                None
2953                            }
2954                        })
2955                        .collect()
2956                };
2957                // Phase C follow-up: if any of these addresses came
2958                // from an invite, verify the invite's claimed fp
2959                // against what we just derived from the pubkey. A
2960                // mismatch means the invite's fp label disagrees with
2961                // libp2p's /p2p/<peer-id> cryptographic anchor —
2962                // structurally impossible when both fields are
2963                // generated from the same identity, but the explicit
2964                // assert defends against future invite-format
2965                // changes or hand-edited links.
2966                let mismatch = {
2967                    let mut map = self.pending_invite_dials.lock().unwrap();
2968                    let mut found: Option<(String, String)> = None;
2969                    for addr in &matched_addrs {
2970                        if let Some(claimed) = map.remove(addr) {
2971                            if claimed != fingerprint {
2972                                found = Some((addr.clone(), claimed));
2973                                break;
2974                            }
2975                        }
2976                    }
2977                    found
2978                };
2979                if let Some((addr, claimed)) = mismatch {
2980                    warn!(
2981                        %addr, %claimed, actual=%fingerprint,
2982                        "invite fingerprint mismatch — disconnecting"
2983                    );
2984                    self.network.disconnect_peer(peer_id).await;
2985                    let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2986                        address: addr,
2987                        claimed,
2988                        actual: fingerprint.clone(),
2989                    });
2990                    return;
2991                }
2992                // huddle 0.7.7: did the local user initiate any of these
2993                // dials? If so, consume the matching entries from
2994                // `pending_auto_dm_addrs` now so we don't auto-DM
2995                // again on a subsequent reconnect. The actual DM
2996                // start happens after the trust upsert below so the
2997                // peer is already marked trusted by the time we fire.
2998                let should_auto_dm = {
2999                    let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
3000                    let mut any_matched = false;
3001                    for addr in &matched_addrs {
3002                        if pending.remove(addr) {
3003                            any_matched = true;
3004                        }
3005                    }
3006                    any_matched
3007                };
3008                for addr in matched_addrs {
3009                    let _ = repo::upsert_known_peer(
3010                        &self.db,
3011                        &KnownPeer {
3012                            address: addr,
3013                            label: None,
3014                            last_connected_at: Some(now_unix()),
3015                            last_attempt_at: Some(now_unix()),
3016                            created_at: now_unix(),
3017                            fingerprint: Some(fingerprint.clone()),
3018                            trusted: true,
3019                        },
3020                    );
3021                }
3022                // huddle 0.7.7: open (or reuse) a DM with the freshly
3023                // identified peer and tell the TUI to switch panes.
3024                // `start_direct` is idempotent on `canonical_dm_room_id`,
3025                // so this is safe to call even if a DM already exists.
3026                //
3027                // huddle 0.7.11: explicitly gate on the persistent
3028                // blocklist here. The original comment claimed blocked
3029                // peers "fall through naturally" but that was only true
3030                // for *inbound* dials — the block check at line ~2237
3031                // is inbound-only. Outbound user-dials hit Identify and
3032                // landed here without ever consulting the blocklist,
3033                // bypassing the user's explicit block.
3034                let blocked = repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false);
3035                if should_auto_dm && !blocked && fingerprint != self.identity.fingerprint() {
3036                    match self.start_direct(&fingerprint).await {
3037                        Ok(room_id) => {
3038                            let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
3039                                room_id,
3040                                fingerprint: fingerprint.clone(),
3041                            });
3042                        }
3043                        Err(e) => {
3044                            debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
3045                        }
3046                    }
3047                }
3048                // huddle 0.5: tell the newly-identified peer our current
3049                // username via a signed ProfileUpdate, but only if we
3050                // have one set locally and we haven't already pushed
3051                // ours to this peer in the last
3052                // `PROFILE_REBROADCAST_FLOOR_MS`. Without the floor a
3053                // flapping transport (relay reconnect storms) would
3054                // republish on every identify event.
3055                let our_username = repo::get_display_name(&self.db).unwrap_or(None);
3056                if our_username.is_some() {
3057                    let now_ms = now_unix_ms();
3058                    let should_send = {
3059                        let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
3060                        match last.get(&fingerprint) {
3061                            Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
3062                            _ => {
3063                                last.insert(fingerprint.clone(), now_ms);
3064                                true
3065                            }
3066                        }
3067                    };
3068                    if should_send {
3069                        let msg = RoomMessage::ProfileUpdate {
3070                            sender_fingerprint: self.identity.fingerprint().to_string(),
3071                            username: our_username,
3072                            updated_at: now_ms,
3073                        };
3074                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
3075                            if let Ok(bytes) =
3076                                crate::network::protocol::encode_wire_signed(&env)
3077                            {
3078                                let rooms: Vec<String> = self
3079                                    .active_rooms
3080                                    .lock()
3081                                    .unwrap()
3082                                    .keys()
3083                                    .cloned()
3084                                    .collect();
3085                                for room_id in rooms {
3086                                    self.network
3087                                        .publish_room_message(room_id, bytes.clone())
3088                                        .await;
3089                                }
3090                            }
3091                        }
3092                    }
3093                }
3094            }
3095            NetworkEvent::RelayReservationEstablished { address } => {
3096                // Treat the circuit address like any other listen
3097                // address — the TUI's ListeningOn handler dedups + adds
3098                // it to the addresses pane. Also emit a status hint via
3099                // ListeningOn so the lobby's reachability line updates.
3100                info!(addr = %address, "relay reservation established");
3101                self.relay_circuit_addrs
3102                    .lock()
3103                    .unwrap()
3104                    .insert(address.to_string());
3105                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
3106                    address: address.to_string(),
3107                });
3108            }
3109            NetworkEvent::NatProbeResult {
3110                tested_addr,
3111                reachable,
3112            } => {
3113                let addr_s = tested_addr.to_string();
3114                let (transitioned, becomes_reachable) = {
3115                    let mut set = self.nat_reachable_addrs.lock().unwrap();
3116                    let was_empty = set.is_empty();
3117                    if reachable {
3118                        set.insert(addr_s.clone());
3119                    } else {
3120                        set.remove(&addr_s);
3121                    }
3122                    let is_empty = set.is_empty();
3123                    (was_empty != is_empty, !is_empty)
3124                };
3125                if transitioned {
3126                    let label = if becomes_reachable {
3127                        "reachable".to_string()
3128                    } else {
3129                        "private".to_string()
3130                    };
3131                    info!(reachable = %becomes_reachable, "NAT reachability changed");
3132                    let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
3133                        label,
3134                        reachable: becomes_reachable,
3135                    });
3136                }
3137            }
3138            NetworkEvent::DcutrUpgrade {
3139                remote_peer,
3140                success,
3141            } => {
3142                if success {
3143                    // Render the peer as the last 8 chars of the
3144                    // PeerId for compactness — full peer id is too long
3145                    // for a status line.
3146                    let s = remote_peer.to_base58();
3147                    let tail: String = s.chars().rev().take(8).collect::<String>()
3148                        .chars()
3149                        .rev()
3150                        .collect();
3151                    let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
3152                        peer_label: tail,
3153                    });
3154                }
3155            }
3156            NetworkEvent::InboundDial {
3157                peer_id,
3158                fingerprint,
3159                address,
3160            } => {
3161                // First: cheap server-side filters before bothering the user.
3162                if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
3163                    info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
3164                    self.network.reject_inbound(peer_id).await;
3165                    return;
3166                }
3167                // Phase E: global verified-only inbound mode. If on,
3168                // reject any unverified fingerprint without prompting.
3169                // SAS-verified (Phase G) and already-trusted (Phase A)
3170                // peers still come through.
3171                let global_verified_only =
3172                    repo::get_setting(&self.db, "verified_only_inbound")
3173                        .ok()
3174                        .flatten()
3175                        .map(|v| v == "1")
3176                        .unwrap_or(false);
3177                if global_verified_only {
3178                    let is_verified =
3179                        repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
3180                            || repo::is_fingerprint_trusted(&self.db, &fingerprint)
3181                                .unwrap_or(false);
3182                    if !is_verified {
3183                        info!(
3184                            %fingerprint,
3185                            "inbound dial auto-rejected: verified-only mode"
3186                        );
3187                        self.network.reject_inbound(peer_id).await;
3188                        return;
3189                    }
3190                }
3191                if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
3192                    info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
3193                    // Persist the address → peer_id mapping just as a
3194                    // user-dial would, so the lobby's online dot lights up.
3195                    self.connected_dial_addrs
3196                        .lock()
3197                        .unwrap()
3198                        .insert(address.to_string(), peer_id);
3199                    let _ = repo::upsert_known_peer(
3200                        &self.db,
3201                        &KnownPeer {
3202                            address: address.to_string(),
3203                            label: None,
3204                            last_connected_at: Some(now_unix()),
3205                            last_attempt_at: Some(now_unix()),
3206                            created_at: now_unix(),
3207                            fingerprint: Some(fingerprint),
3208                            trusted: true,
3209                        },
3210                    );
3211                    self.network.accept_inbound(peer_id).await;
3212                    return;
3213                }
3214                // Unknown peer — surface the modal in the TUI.
3215                let _ = self.app_event_tx.send(AppEvent::InboundDial {
3216                    peer_id,
3217                    fingerprint,
3218                    address: address.to_string(),
3219                });
3220            }
3221        }
3222    }
3223
3224    /// `verified_signer` is `Some(fp)` if this message arrived inside a
3225    /// successfully-verified `WireMessage::Signed` envelope — in which
3226    /// case the inner sender_fingerprint *must* match. `None` for
3227    /// `WireMessage::Plain`. Phase B's `OwnerGrant`/`BanMember` arms
3228    /// require it to be `Some` AND the signer to be a current owner.
3229    ///
3230    /// INVARIANT (huddle 1.1.4): never hold a `std::sync::Mutex` guard
3231    /// (`active_rooms`, `sas_flows`, the DB) across an `.await`. Always
3232    /// scope the guard in its own block or `drop()` it before awaiting —
3233    /// see the DM-key path below. This is also enforced mechanically:
3234    /// this fn runs inside a `Send` task, so a `!Send` `MutexGuard` held
3235    /// across `.await` would fail to compile.
3236    async fn handle_room_message(
3237        &self,
3238        room_id: &str,
3239        msg: RoomMessage,
3240        verified_signer: Option<String>,
3241    ) {
3242        let our_fp = self.identity.fingerprint().to_string();
3243        // huddle 1.2: lazily re-activate a known DM that isn't currently in
3244        // active_rooms before dispatching. Otherwise the first inbound message
3245        // or MemberAnnounce (which carries the session key!) for a DM that was
3246        // parked as `restorable` (partner pubkey unknown at restore) or simply
3247        // closed this session is silently dropped by the per-arm
3248        // `active_rooms.get(room_id) -> None => return` guards — and the DM
3249        // appears dead. Only DM rooms that ALREADY exist on disk with a known
3250        // partner are auto-activated here; group rooms (which need a
3251        // passphrase) and unknown rooms are left untouched.
3252        {
3253            let known_inactive = !self.active_rooms.lock().unwrap().contains_key(room_id);
3254            if known_inactive {
3255                if let Ok(Some(info)) = repo::get_room(&self.db, room_id) {
3256                    if info.kind == RoomKind::Direct {
3257                        let partner = repo::list_room_members(&self.db, room_id)
3258                            .ok()
3259                            .into_iter()
3260                            .flatten()
3261                            .map(|m| m.fingerprint)
3262                            .find(|fp| *fp != our_fp);
3263                        if let Some(partner_fp) = partner {
3264                            if let Err(e) =
3265                                self.bootstrap_direct_room(room_id, &partner_fp).await
3266                            {
3267                                debug!(%e, %room_id, "lazy DM re-activation on inbound failed");
3268                            }
3269                        }
3270                    }
3271                }
3272            }
3273        }
3274        match msg {
3275            RoomMessage::MemberAnnounce {
3276                sender_fingerprint,
3277                wrapped_session_key,
3278                display_name,
3279                sender_ed25519_pubkey,
3280            } => {
3281                if sender_fingerprint == our_fp {
3282                    return;
3283                }
3284                // huddle 0.7.11: MemberAnnounce must arrive inside a
3285                // signed envelope, and the signer's fingerprint must
3286                // match the claimed announcer. Closes the TOFU-pubkey
3287                // hijack: pre-0.7.11 a malicious peer could race a
3288                // victim's first announce on a room and pin a fabricated
3289                // ed25519 pubkey under the victim's fingerprint, so honest
3290                // peers would later reject the real victim's signed
3291                // messages. Now the inner `sender_ed25519_pubkey` is
3292                // ignored — the envelope's pubkey is the authoritative one.
3293                let signer = match verified_signer {
3294                    Some(fp) => fp,
3295                    None => {
3296                        warn!(%sender_fingerprint, %room_id, "MemberAnnounce arrived unsigned; dropping");
3297                        return;
3298                    }
3299                };
3300                if signer != sender_fingerprint {
3301                    warn!(%signer, %sender_fingerprint, %room_id, "MemberAnnounce signer mismatch; dropping");
3302                    return;
3303                }
3304                // Drop announcements from banned fingerprints — they
3305                // can't rejoin until an owner unbans them (Phase B).
3306                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3307                    .unwrap_or(false)
3308                {
3309                    info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
3310                    return;
3311                }
3312                // Phase E per-room enforcement: if this room is
3313                // verified-only and the joiner isn't globally SAS-
3314                // verified, refuse to add them. The lowest-fp owner
3315                // (deterministic across honest peers) also sends a
3316                // signed `JoinRefused` so the joiner gets an explicit
3317                // message instead of a silent hang.
3318                if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3319                    && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
3320                {
3321                    info!(
3322                        %sender_fingerprint, %room_id,
3323                        "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
3324                    );
3325                    let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
3326                    let lowest_owner = owners.iter().min().cloned();
3327                    if lowest_owner.as_deref() == Some(&our_fp) {
3328                        let msg = RoomMessage::JoinRefused {
3329                            room_id: room_id.to_string(),
3330                            target_fingerprint: sender_fingerprint.clone(),
3331                            reason: "room requires SAS verification — ask an existing member to verify you".into(),
3332                        };
3333                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
3334                            if let Ok(bytes) =
3335                                crate::network::protocol::encode_wire_signed(&env)
3336                            {
3337                                self.network
3338                                    .publish_room_message(room_id.to_string(), bytes)
3339                                    .await;
3340                            }
3341                        }
3342                    }
3343                    return;
3344                }
3345                let need_inbound = {
3346                    let mut rooms = self.active_rooms.lock().unwrap();
3347                    let room = match rooms.get_mut(room_id) {
3348                        Some(r) => r,
3349                        None => return,
3350                    };
3351                    // huddle 0.7: Direct rooms are 1-1 forever. If a
3352                    // third fingerprint announces, drop it locally and
3353                    // skip the persist/wrap-session path. This is honest-
3354                    // client enforcement — a malicious peer with the
3355                    // canonical DM passphrase-equivalent could still
3356                    // chat, but they'd never be visible in our sidebar
3357                    // or render in the DM pane.
3358                    if room.info.kind == RoomKind::Direct
3359                        && !room.members.contains(&sender_fingerprint)
3360                        && room.members.len() >= 2
3361                    {
3362                        info!(
3363                            %sender_fingerprint, %room_id,
3364                            "dropping MemberAnnounce on Direct room: already at 2-member cap"
3365                        );
3366                        return;
3367                    }
3368                    let newly_added = room.members.insert(sender_fingerprint.clone());
3369                    if newly_added {
3370                        let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3371                            room_id: room_id.to_string(),
3372                            fingerprint: sender_fingerprint.clone(),
3373                        });
3374                    }
3375                    // Persist member with optional display name + pubkey.
3376                    // `ed25519_pubkey` is `None` for pre-0.3 peers; the
3377                    // upsert COALESCEs so once we learn it we never lose
3378                    // it on a later announce that drops the field.
3379                    let _ = repo::upsert_room_member(
3380                        &self.db,
3381                        &StoredRoomMember {
3382                            room_id: room_id.to_string(),
3383                            peer_id: String::new(), // unknown at this layer
3384                            fingerprint: sender_fingerprint.clone(),
3385                            last_seen: Some(now_unix()),
3386                            verified: false,
3387                            ed25519_pubkey: sender_ed25519_pubkey.clone(),
3388                            // Role is set on first insert only — the
3389                            // upsert ON CONFLICT clause preserves an
3390                            // existing 'owner' on re-announce. A genuine
3391                            // new fingerprint is a 'member' until an
3392                            // OwnerGrant lands.
3393                            role: "member".into(),
3394                        },
3395                    );
3396                    if let Some(name) = display_name.as_deref() {
3397                        let _ = repo::set_member_display_name(
3398                            &self.db,
3399                            room_id,
3400                            &sender_fingerprint,
3401                            Some(name),
3402                        );
3403                    }
3404                    room.info.encrypted && wrapped_session_key.is_some()
3405                };
3406
3407                // huddle 0.7.1: for Direct rooms, the passphrase_key is
3408                // derived from ECDH between our identity key and the
3409                // partner's. The partner's pubkey may arrive in *this*
3410                // MemberAnnounce — so we lazily compute the key now,
3411                // before the unwrap path runs. Idempotent: if we
3412                // already have the key, this is a no-op.
3413                if matches!(
3414                    self.active_rooms
3415                        .lock()
3416                        .unwrap()
3417                        .get(room_id)
3418                        .map(|r| (r.info.kind, r.passphrase_key.is_none())),
3419                    Some((RoomKind::Direct, true))
3420                ) {
3421                    if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
3422                        if let Some(key) =
3423                            self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
3424                        {
3425                            let mut rooms = self.active_rooms.lock().unwrap();
3426                            if let Some(room) = rooms.get_mut(room_id) {
3427                                room.passphrase_key = Some(key);
3428                            }
3429                            drop(rooms);
3430                            // We just got the key — re-broadcast our
3431                            // MemberAnnounce so the partner gets our
3432                            // wrapped session key. Fire-and-forget;
3433                            // failures are logged.
3434                            let app = self.clone();
3435                            let rid = room_id.to_string();
3436                            tokio::spawn(async move {
3437                                if let Err(e) = app.broadcast_member_announce(&rid).await {
3438                                    warn!(%e, "re-broadcast DM announce after key derivation");
3439                                }
3440                            });
3441                        }
3442                    }
3443                }
3444
3445                if need_inbound {
3446                    let wrapped = wrapped_session_key.unwrap();
3447                    let result = {
3448                        let mut rooms = self.active_rooms.lock().unwrap();
3449                        let room = rooms.get_mut(room_id).unwrap();
3450                        let passphrase_key = match &room.passphrase_key {
3451                            Some(k) => k,
3452                            None => {
3453                                warn!("no passphrase key when receiving session key");
3454                                return;
3455                            }
3456                        };
3457                        match passphrase::unwrap(&wrapped, passphrase_key) {
3458                            Ok(plain) => match String::from_utf8(plain) {
3459                                Ok(key_b64) => {
3460                                    let crypto = room.crypto.as_mut().unwrap();
3461                                    crypto.add_inbound_session(&sender_fingerprint, &key_b64)
3462                                }
3463                                Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
3464                            },
3465                            Err(e) => Err(e),
3466                        }
3467                    };
3468                    if let Err(e) = result {
3469                        error!(%e, "add inbound session failed");
3470                    }
3471                }
3472            }
3473            RoomMessage::SessionKeyRequest {
3474                requester_fingerprint,
3475            } => {
3476                if requester_fingerprint == our_fp {
3477                    return;
3478                }
3479                // Re-announce ourselves to share our session key with the new joiner.
3480                if let Err(e) = self.broadcast_member_announce(room_id).await {
3481                    warn!(%e, "broadcast member announce on request");
3482                }
3483            }
3484            RoomMessage::Encrypted {
3485                sender_fingerprint,
3486                session_id,
3487                ciphertext_b64,
3488            } => {
3489                if sender_fingerprint == our_fp {
3490                    return;
3491                }
3492                // huddle 0.7.11: ban filter on every content-bearing arm.
3493                // Pre-0.7.11 only MemberAnnounce was filtered, so banned
3494                // peers could still post Encrypted/Plain after a kick
3495                // (cosmetically in encrypted rooms post-rotation since
3496                // they have no inbound session, but in unencrypted rooms
3497                // their plaintext rendered freely — see RoomMessage::Plain
3498                // arm below).
3499                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3500                    .unwrap_or(false)
3501                {
3502                    debug!(%sender_fingerprint, %room_id, "dropping Encrypted from banned peer");
3503                    return;
3504                }
3505                let ct_bytes = match base64::Engine::decode(
3506                    &base64::engine::general_purpose::STANDARD,
3507                    &ciphertext_b64,
3508                ) {
3509                    Ok(b) => b,
3510                    Err(e) => {
3511                        warn!(%e, "bad base64 ciphertext");
3512                        return;
3513                    }
3514                };
3515                let plaintext = {
3516                    let mut rooms = self.active_rooms.lock().unwrap();
3517                    let room = match rooms.get_mut(room_id) {
3518                        Some(r) => r,
3519                        None => return,
3520                    };
3521                    let crypto = match room.crypto.as_mut() {
3522                        Some(c) => c,
3523                        None => return,
3524                    };
3525                    crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
3526                };
3527                match plaintext {
3528                    Ok(pt) => {
3529                        let body = String::from_utf8_lossy(&pt).to_string();
3530                        let sent_at = now_unix();
3531                        let _ = repo::insert_room_message(
3532                            &self.db,
3533                            room_id,
3534                            &sender_fingerprint,
3535                            "in",
3536                            &body,
3537                            sent_at,
3538                        );
3539                        let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
3540                        self.maybe_emit_mention(room_id, &body);
3541                        let _ = self.app_event_tx.send(AppEvent::MessageReceived {
3542                            room_id: room_id.to_string(),
3543                            sender_fingerprint,
3544                            body,
3545                            sent_at,
3546                        });
3547                    }
3548                    Err(e) => {
3549                        debug!(%e, "decrypt failed (probably missing session key)");
3550                    }
3551                }
3552            }
3553            RoomMessage::Plain {
3554                sender_fingerprint,
3555                body,
3556            } => {
3557                if sender_fingerprint == our_fp {
3558                    return;
3559                }
3560                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3561                    .unwrap_or(false)
3562                {
3563                    debug!(%sender_fingerprint, %room_id, "dropping Plain from banned peer");
3564                    return;
3565                }
3566                let sent_at = now_unix();
3567                let _ = repo::insert_room_message(
3568                    &self.db,
3569                    room_id,
3570                    &sender_fingerprint,
3571                    "in",
3572                    &body,
3573                    sent_at,
3574                );
3575                let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
3576                self.maybe_emit_mention(room_id, &body);
3577                let _ = self.app_event_tx.send(AppEvent::MessageReceived {
3578                    room_id: room_id.to_string(),
3579                    sender_fingerprint,
3580                    body,
3581                    sent_at,
3582                });
3583            }
3584            RoomMessage::Typing { sender_fingerprint } => {
3585                if sender_fingerprint == our_fp {
3586                    return;
3587                }
3588                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3589                    .unwrap_or(false)
3590                {
3591                    return;
3592                }
3593                let expiry = now_unix() + TYPING_TTL_SECS;
3594                let mut rooms = self.active_rooms.lock().unwrap();
3595                if let Some(room) = rooms.get_mut(room_id) {
3596                    room.typers.insert(sender_fingerprint, expiry);
3597                }
3598                drop(rooms);
3599                let _ = self.app_event_tx.send(AppEvent::TypingChanged {
3600                    room_id: room_id.to_string(),
3601                });
3602            }
3603            RoomMessage::RotateRoomKey {
3604                rotator_fingerprint,
3605                new_salt,
3606            } => {
3607                if rotator_fingerprint == our_fp {
3608                    return;
3609                }
3610                // Rotations are self-attested: the signer must be the
3611                // claimed rotator. Unsigned forgeries land in
3612                // `verified_signer = None` and are dropped here, as are
3613                // signed envelopes where the signer fp doesn't match.
3614                let signer = match verified_signer {
3615                    Some(fp) => fp,
3616                    None => {
3617                        warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
3618                        return;
3619                    }
3620                };
3621                if signer != rotator_fingerprint {
3622                    warn!(
3623                        %signer, %rotator_fingerprint, %room_id,
3624                        "RotateRoomKey signer mismatch with claimed rotator; dropping"
3625                    );
3626                    return;
3627                }
3628                let _ = self.app_event_tx.send(AppEvent::RotationRequested {
3629                    room_id: room_id.to_string(),
3630                    rotator_fingerprint,
3631                    new_salt,
3632                });
3633            }
3634            RoomMessage::MemberLeave { sender_fingerprint } => {
3635                if sender_fingerprint == our_fp {
3636                    return;
3637                }
3638                // huddle 0.7.11: MemberLeave must arrive inside a signed
3639                // envelope whose signer matches the claimed leaver.
3640                // Pre-0.7.11 plain leaves and forged leaves are dropped.
3641                let signer = match verified_signer {
3642                    Some(fp) => fp,
3643                    None => {
3644                        warn!(%sender_fingerprint, %room_id, "MemberLeave arrived unsigned; dropping");
3645                        return;
3646                    }
3647                };
3648                if signer != sender_fingerprint {
3649                    warn!(%signer, %sender_fingerprint, %room_id, "MemberLeave signer mismatch; dropping");
3650                    return;
3651                }
3652                let removed = {
3653                    let mut rooms = self.active_rooms.lock().unwrap();
3654                    if let Some(room) = rooms.get_mut(room_id) {
3655                        room.members.remove(&sender_fingerprint)
3656                    } else {
3657                        false
3658                    }
3659                };
3660                if removed {
3661                    let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3662                        room_id: room_id.to_string(),
3663                        fingerprint: sender_fingerprint,
3664                    });
3665                }
3666            }
3667            RoomMessage::FileOffer {
3668                sender_fingerprint,
3669                file_id,
3670                name,
3671                size_bytes,
3672                mime,
3673                chunk_count,
3674                encrypted_meta,
3675            } => {
3676                if sender_fingerprint == our_fp {
3677                    return; // ignore our own broadcast
3678                }
3679                // huddle 0.7.11: FileOffer must be signed so peers can't
3680                // spoof attribution. The chunk stream itself stays plain
3681                // (sha256 over the assembly is the integrity gate), but
3682                // who *announced* the file is now bound to the signer.
3683                let signer = match verified_signer {
3684                    Some(fp) => fp,
3685                    None => {
3686                        warn!(%sender_fingerprint, %room_id, %file_id, "FileOffer arrived unsigned; dropping");
3687                        return;
3688                    }
3689                };
3690                if signer != sender_fingerprint {
3691                    warn!(%signer, %sender_fingerprint, %room_id, %file_id, "FileOffer signer mismatch; dropping");
3692                    return;
3693                }
3694                // Drop offers from banned peers in the same shape as
3695                // MemberAnnounce — keeps moderation invariant tight.
3696                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3697                    .unwrap_or(false)
3698                {
3699                    info!(%sender_fingerprint, %room_id, %file_id, "dropping FileOffer from banned peer");
3700                    return;
3701                }
3702                self.handle_file_offer(
3703                    room_id,
3704                    sender_fingerprint,
3705                    file_id,
3706                    name,
3707                    size_bytes,
3708                    mime,
3709                    chunk_count,
3710                    encrypted_meta,
3711                );
3712            }
3713            RoomMessage::FileChunk {
3714                sender_fingerprint,
3715                file_id,
3716                chunk_index,
3717                total_chunks,
3718                data_b64,
3719            } => {
3720                if sender_fingerprint == our_fp {
3721                    return;
3722                }
3723                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3724                    .unwrap_or(false)
3725                {
3726                    return;
3727                }
3728                self.handle_file_chunk(
3729                    room_id,
3730                    sender_fingerprint,
3731                    file_id,
3732                    chunk_index,
3733                    total_chunks,
3734                    data_b64,
3735                );
3736            }
3737            RoomMessage::OwnerGrant {
3738                room_id: announced_room_id,
3739                target_fingerprint,
3740            } => {
3741                // Both: payload room_id must match the topic's room_id
3742                // (no cross-room replay), AND the signer must be a
3743                // current owner of this room. Unsigned forgeries land in
3744                // `verified_signer = None` and are dropped here.
3745                if announced_room_id != room_id {
3746                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
3747                    return;
3748                }
3749                let signer = match verified_signer {
3750                    Some(fp) => fp,
3751                    None => {
3752                        warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
3753                        return;
3754                    }
3755                };
3756                if !self.is_owner(room_id, &signer) {
3757                    warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
3758                    return;
3759                }
3760                info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
3761                if let Err(e) =
3762                    repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
3763                {
3764                    warn!(%e, "OwnerGrant: set_member_role failed");
3765                }
3766            }
3767            RoomMessage::BanMember {
3768                room_id: announced_room_id,
3769                target_fingerprint,
3770            } => {
3771                if announced_room_id != room_id {
3772                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
3773                    return;
3774                }
3775                let signer = match verified_signer {
3776                    Some(fp) => fp,
3777                    None => {
3778                        warn!(%room_id, "BanMember arrived unsigned; dropping");
3779                        return;
3780                    }
3781                };
3782                if !self.is_owner(room_id, &signer) {
3783                    warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
3784                    return;
3785                }
3786                if target_fingerprint == our_fp {
3787                    // We've been kicked. Locally evict ourselves so the
3788                    // TUI tabs close; the kicker's subsequent
3789                    // RotateRoomKey will arrive separately and we
3790                    // simply won't be able to decrypt the new key,
3791                    // matching the "soft kick" semantics.
3792                    info!(%room_id, %signer, "we were kicked from this room");
3793                    self.active_rooms.lock().unwrap().remove(room_id);
3794                    let _ = self.app_event_tx.send(AppEvent::RoomLeft {
3795                        room_id: room_id.to_string(),
3796                    });
3797                    return;
3798                }
3799                info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
3800                if let Err(e) = repo::add_room_ban(
3801                    &self.db,
3802                    room_id,
3803                    &target_fingerprint,
3804                    &signer,
3805                    "", // signature lives in the envelope, not the row
3806                    now_unix(),
3807                ) {
3808                    warn!(%e, "BanMember: add_room_ban failed");
3809                }
3810                self.evict_banned_member(room_id, &target_fingerprint);
3811            }
3812            RoomMessage::SasInit {
3813                tx_id,
3814                ephemeral_x25519_pubkey_b64,
3815                target_fingerprint,
3816            } => {
3817                if target_fingerprint != our_fp {
3818                    // Not addressed to us — ignore. Phase G is point-
3819                    // to-point even though it travels over the room
3820                    // topic, so members of the room who aren't the
3821                    // target don't need to act.
3822                    return;
3823                }
3824                let signer = match verified_signer {
3825                    Some(fp) => fp,
3826                    None => {
3827                        warn!("SasInit arrived unsigned; dropping");
3828                        return;
3829                    }
3830                };
3831                let their_pub =
3832                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
3833                        Ok(pk) => pk,
3834                        Err(e) => {
3835                            warn!(%e, "SasInit: bad x25519 pubkey");
3836                            return;
3837                        }
3838                    };
3839                let tx_id_bytes = match B64.decode(&tx_id) {
3840                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
3841                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
3842                        arr.copy_from_slice(&b);
3843                        arr
3844                    }
3845                    _ => {
3846                        warn!(%tx_id, "SasInit: bad tx_id length");
3847                        return;
3848                    }
3849                };
3850                let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
3851                let sas_code = match crate::crypto::sas::derive_sas_code(
3852                    &our_secret,
3853                    &their_pub,
3854                    &tx_id_bytes,
3855                ) {
3856                    Ok(c) => c,
3857                    Err(e) => {
3858                        warn!(%e, "SasInit: rejecting non-contributory ephemeral; dropping");
3859                        return;
3860                    }
3861                };
3862                self.sas_flows.lock().unwrap().insert(
3863                    tx_id.clone(),
3864                    SasFlow {
3865                        room_id: room_id.to_string(),
3866                        partner_fingerprint: signer.clone(),
3867                        our_secret,
3868                        sas_code: Some(sas_code.clone()),
3869                        our_confirmed: false,
3870                        their_confirmed: false,
3871                        finalized: false,
3872                    },
3873                );
3874                // Respond with our pubkey so the initiator can compute
3875                // the same code.
3876                let response = RoomMessage::SasResponse {
3877                    tx_id: tx_id.clone(),
3878                    ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3879                };
3880                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
3881                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3882                        self.network
3883                            .publish_room_message(room_id.to_string(), bytes)
3884                            .await;
3885                    }
3886                }
3887                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3888                    room_id: room_id.to_string(),
3889                    partner_fingerprint: signer,
3890                    tx_id,
3891                    emoji_labels: sas_code.emoji_labels(),
3892                    decimal: sas_code.decimal,
3893                });
3894            }
3895            RoomMessage::SasResponse {
3896                tx_id,
3897                ephemeral_x25519_pubkey_b64,
3898            } => {
3899                let signer = match verified_signer {
3900                    Some(fp) => fp,
3901                    None => {
3902                        warn!("SasResponse arrived unsigned; dropping");
3903                        return;
3904                    }
3905                };
3906                let their_pub =
3907                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
3908                        Ok(pk) => pk,
3909                        Err(e) => {
3910                            warn!(%e, "SasResponse: bad x25519 pubkey");
3911                            return;
3912                        }
3913                    };
3914                let tx_id_bytes = match B64.decode(&tx_id) {
3915                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
3916                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
3917                        arr.copy_from_slice(&b);
3918                        arr
3919                    }
3920                    _ => return,
3921                };
3922                let emit = {
3923                    let mut flows = self.sas_flows.lock().unwrap();
3924                    let flow = match flows.get_mut(&tx_id) {
3925                        Some(f) => f,
3926                        None => {
3927                            warn!(%tx_id, "SasResponse for unknown tx_id");
3928                            return;
3929                        }
3930                    };
3931                    if flow.partner_fingerprint != signer {
3932                        warn!(
3933                            expected = %flow.partner_fingerprint, got = %signer,
3934                            "SasResponse signer doesn't match flow's partner; dropping"
3935                        );
3936                        return;
3937                    }
3938                    let code = match crate::crypto::sas::derive_sas_code(
3939                        &flow.our_secret,
3940                        &their_pub,
3941                        &tx_id_bytes,
3942                    ) {
3943                        Ok(c) => c,
3944                        Err(e) => {
3945                            warn!(%e, "SasResponse: rejecting non-contributory ephemeral; dropping");
3946                            return;
3947                        }
3948                    };
3949                    flow.sas_code = Some(code.clone());
3950                    code
3951                };
3952                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3953                    room_id: room_id.to_string(),
3954                    partner_fingerprint: signer,
3955                    tx_id,
3956                    emoji_labels: emit.emoji_labels(),
3957                    decimal: emit.decimal,
3958                });
3959            }
3960            RoomMessage::CodeJoinRequest {
3961                room_id: announced_room_id,
3962                joiner_x25519_pubkey_b64,
3963                code,
3964            } => {
3965                if announced_room_id != room_id {
3966                    return;
3967                }
3968                let joiner_fp = match verified_signer {
3969                    Some(fp) => fp,
3970                    None => {
3971                        warn!("CodeJoinRequest unsigned; dropping");
3972                        return;
3973                    }
3974                };
3975                // Only owners with an active code are interested in
3976                // responding. Other peers (incl. non-issuing owners)
3977                // simply ignore.
3978                let our_fp = self.identity.fingerprint().to_string();
3979                if !self.is_owner(room_id, &our_fp) {
3980                    return;
3981                }
3982                // Match + consume the code. Single use.
3983                let now = now_unix();
3984                let (code_ok, our_session_id, wrap_input) = {
3985                    let mut rooms = self.active_rooms.lock().unwrap();
3986                    let room = match rooms.get_mut(room_id) {
3987                        Some(r) => r,
3988                        None => return,
3989                    };
3990                    if room.passphrase_key.is_none() {
3991                        warn!("CodeJoinRequest: no passphrase key locally; can't respond");
3992                        return;
3993                    }
3994                    let original_len = room.issued_codes.len();
3995                    room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
3996                    let matched = room.issued_codes.len() < original_len;
3997                    if !matched {
3998                        info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
3999                        return;
4000                    }
4001                    let crypto = room.crypto.as_ref().unwrap();
4002                    (
4003                        true,
4004                        crypto.our_session_id(),
4005                        crypto.our_session_key_b64(),
4006                    )
4007                };
4008                let _ = code_ok;
4009                // ECDH with the joiner's ephemeral pubkey.
4010                let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
4011                    Ok(pk) => pk,
4012                    Err(e) => {
4013                        warn!(%e, "CodeJoinRequest: bad pubkey");
4014                        return;
4015                    }
4016                };
4017                use x25519_dalek::{PublicKey, StaticSecret};
4018                let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
4019                let our_pub = PublicKey::from(&our_secret);
4020                let shared = our_secret.diffie_hellman(&their_pub);
4021                // HKDF the shared secret into a 32-byte wrap key.
4022                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
4023                let mut wrap_key = [0u8; passphrase::KEY_LEN];
4024                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
4025                    .expect("32 bytes is within HKDF limits");
4026                // Wrap our session key under the ECDH-derived key,
4027                // reusing the existing AEAD primitives.
4028                let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
4029                    Ok(w) => w,
4030                    Err(e) => {
4031                        warn!(%e, "CodeJoinRequest: wrap failed");
4032                        return;
4033                    }
4034                };
4035                let response = RoomMessage::CodeJoinResponse {
4036                    room_id: room_id.to_string(),
4037                    target_fingerprint: joiner_fp.clone(),
4038                    owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4039                    owner_session_id: our_session_id,
4040                    wrapped_session_key_b64: wrapped,
4041                    nonce_b64: String::new(), // nonce is embedded in `wrapped` per passphrase::wrap
4042                };
4043                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
4044                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4045                        self.network
4046                            .publish_room_message(room_id.to_string(), bytes)
4047                            .await;
4048                    }
4049                }
4050                info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
4051            }
4052            RoomMessage::CodeJoinResponse {
4053                room_id: announced_room_id,
4054                target_fingerprint,
4055                owner_x25519_pubkey_b64,
4056                owner_session_id,
4057                wrapped_session_key_b64,
4058                nonce_b64: _,
4059            } => {
4060                if announced_room_id != room_id || target_fingerprint != our_fp {
4061                    return;
4062                }
4063                let owner_fp = match verified_signer {
4064                    Some(fp) => fp,
4065                    None => {
4066                        warn!("CodeJoinResponse unsigned; dropping");
4067                        return;
4068                    }
4069                };
4070                let our_secret = match self
4071                    .pending_code_secrets
4072                    .lock()
4073                    .unwrap()
4074                    .remove(&(room_id.to_string(), our_fp.clone()))
4075                {
4076                    Some(s) => s,
4077                    None => {
4078                        warn!(%room_id, "CodeJoinResponse with no pending code-join state");
4079                        return;
4080                    }
4081                };
4082                let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
4083                    Ok(pk) => pk,
4084                    Err(e) => {
4085                        warn!(%e, "CodeJoinResponse: bad owner pubkey");
4086                        return;
4087                    }
4088                };
4089                let shared = our_secret.diffie_hellman(&owner_pub);
4090                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
4091                let mut wrap_key = [0u8; passphrase::KEY_LEN];
4092                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
4093                    .expect("32 bytes within HKDF limits");
4094                let session_key_bytes =
4095                    match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
4096                        Ok(b) => b,
4097                        Err(e) => {
4098                            warn!(%e, "CodeJoinResponse: unwrap failed");
4099                            return;
4100                        }
4101                    };
4102                let session_key_str = match String::from_utf8(session_key_bytes) {
4103                    Ok(s) => s,
4104                    Err(e) => {
4105                        warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
4106                        return;
4107                    }
4108                };
4109                // Install as an inbound session keyed by the owner's fp.
4110                let mut rooms = self.active_rooms.lock().unwrap();
4111                if let Some(room) = rooms.get_mut(room_id) {
4112                    if let Some(crypto) = room.crypto.as_mut() {
4113                        if let Err(e) =
4114                            crypto.add_inbound_session(&owner_fp, &session_key_str)
4115                        {
4116                            warn!(%e, "CodeJoinResponse: add_inbound_session failed");
4117                        } else {
4118                            info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
4119                            room.members.insert(owner_fp.clone());
4120                            let _ = self.app_event_tx.send(AppEvent::MemberJoined {
4121                                room_id: room_id.to_string(),
4122                                fingerprint: owner_fp,
4123                            });
4124                        }
4125                    }
4126                }
4127            }
4128            RoomMessage::JoinRefused {
4129                room_id: announced_room_id,
4130                target_fingerprint,
4131                reason,
4132            } => {
4133                if announced_room_id != room_id || target_fingerprint != our_fp {
4134                    return;
4135                }
4136                // Surface the refusal as an Error so the user sees why
4137                // their join didn't take. The Phase 3 modal-queue rule
4138                // means this won't clobber typing in another modal.
4139                let _ = self.app_event_tx.send(AppEvent::Error {
4140                    description: format!("join refused: {reason}"),
4141                });
4142            }
4143            RoomMessage::SasConfirm { tx_id, matched } => {
4144                let signer = match verified_signer {
4145                    Some(fp) => fp,
4146                    None => return,
4147                };
4148                let (room_id_done, partner_fp_done, both_done) = {
4149                    let mut flows = self.sas_flows.lock().unwrap();
4150                    let flow = match flows.get_mut(&tx_id) {
4151                        Some(f) => f,
4152                        None => return,
4153                    };
4154                    if flow.partner_fingerprint != signer {
4155                        return;
4156                    }
4157                    if !matched {
4158                        // Partner declined / mismatch — drop the flow.
4159                        let _ = flow;
4160                        flows.remove(&tx_id);
4161                        return;
4162                    }
4163                    flow.their_confirmed = true;
4164                    // huddle 0.7.11: only fire finalize from this arm
4165                    // when the flow hasn't already been finalized by
4166                    // the local `sas_match` path. The `finalized`
4167                    // latch is set inside `finish_sas` (taken under
4168                    // this same Mutex), so the two paths can't both
4169                    // observe it as `false`.
4170                    if flow.our_confirmed && flow.their_confirmed && !flow.finalized {
4171                        flow.finalized = true;
4172                        (
4173                            Some(flow.room_id.clone()),
4174                            Some(flow.partner_fingerprint.clone()),
4175                            true,
4176                        )
4177                    } else {
4178                        (None, None, false)
4179                    }
4180                };
4181                if both_done {
4182                    if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
4183                        if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
4184                            warn!(%e, "finish_sas failed");
4185                        }
4186                    }
4187                }
4188            }
4189            RoomMessage::ProfileUpdate {
4190                sender_fingerprint,
4191                username,
4192                updated_at,
4193            } => {
4194                // huddle 0.5: username spoof defense. Drop any
4195                // ProfileUpdate that didn't arrive inside a Signed
4196                // envelope, or whose signer doesn't match the claimed
4197                // sender_fingerprint. Without this anyone could pretend
4198                // to be "alice" by stuffing the field.
4199                let signer = match verified_signer {
4200                    Some(fp) => fp,
4201                    None => {
4202                        warn!(
4203                            sender = %sender_fingerprint,
4204                            "dropping unsigned ProfileUpdate"
4205                        );
4206                        return;
4207                    }
4208                };
4209                if signer != sender_fingerprint {
4210                    warn!(
4211                        signer = %signer,
4212                        claimed = %sender_fingerprint,
4213                        "dropping ProfileUpdate with signer != sender"
4214                    );
4215                    return;
4216                }
4217                if let Err(e) = repo::upsert_peer_profile(
4218                    &self.db,
4219                    &sender_fingerprint,
4220                    username.as_deref(),
4221                    updated_at,
4222                ) {
4223                    warn!(%e, "upsert_peer_profile failed");
4224                    return;
4225                }
4226                let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
4227                    fingerprint: sender_fingerprint,
4228                    username,
4229                });
4230            }
4231            RoomMessage::ContactRequest {
4232                requester_fingerprint,
4233                display_name,
4234                note,
4235                sender_ed25519_pubkey: _,
4236            } => {
4237                // Only honor a contact request that arrived on OUR own inbox
4238                // room — never one published into a shared room topic.
4239                if room_id != crate::network::protocol::inbox_room_id(&our_fp) {
4240                    return;
4241                }
4242                // Must be signed, and the signer must BE the requester — the
4243                // signature is the whole proof of who's asking.
4244                let signer = match verified_signer {
4245                    Some(fp) => fp,
4246                    None => {
4247                        warn!(%requester_fingerprint, "dropping unsigned ContactRequest");
4248                        return;
4249                    }
4250                };
4251                if signer != requester_fingerprint || requester_fingerprint == our_fp {
4252                    return;
4253                }
4254                if repo::is_peer_blocked(&self.db, &requester_fingerprint).unwrap_or(false) {
4255                    debug!(%requester_fingerprint, "ignoring ContactRequest from blocked peer");
4256                    return;
4257                }
4258                // Mutual case: if this fingerprint is already in our address
4259                // book (we requested them, or we're already connected), treat
4260                // their request as acceptance — open/refresh the DM directly,
4261                // no prompt. This is also how the acceptor's echo-back
4262                // converges the relay path: both sides end up subscribed to
4263                // the canonical DM room, after which the normal MemberAnnounce
4264                // exchange shares session keys.
4265                if self.is_contact(&requester_fingerprint) {
4266                    let _ =
4267                        repo::delete_pending_contact_request(&self.db, &requester_fingerprint);
4268                    if let Err(e) = self.start_direct(&requester_fingerprint).await {
4269                        debug!(%e, "ContactRequest mutual: start_direct failed");
4270                    }
4271                    return;
4272                }
4273                // Fresh inbound request — persist + surface for the user to
4274                // accept or decline from the Contacts pane.
4275                if let Err(e) = repo::upsert_pending_contact_request(
4276                    &self.db,
4277                    &repo::PendingContactRequest {
4278                        fingerprint: requester_fingerprint.clone(),
4279                        display_name: display_name.clone(),
4280                        note: note.clone(),
4281                        received_at: now_unix(),
4282                    },
4283                ) {
4284                    warn!(%e, "upsert pending contact request failed");
4285                    return;
4286                }
4287                let _ = self.app_event_tx.send(AppEvent::ContactRequestReceived {
4288                    fingerprint: requester_fingerprint,
4289                    display_name,
4290                    note,
4291                });
4292            }
4293        }
4294    }
4295
4296    // -------------------------------------------------------------------
4297    // File transfer — public API
4298    // -------------------------------------------------------------------
4299
4300    /// Send a local file to a room. Reads the file, optionally encrypts
4301    /// it for encrypted rooms, chunks it, broadcasts a FileOffer then
4302    /// each FileChunk. Returns the file_id once all chunks are queued.
4303    pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
4304        let bytes = std::fs::read(path)?;
4305        let name = path
4306            .file_name()
4307            .map(|n| n.to_string_lossy().to_string())
4308            .unwrap_or_else(|| "untitled".into());
4309        let mime = crate::files::guess_mime(&name);
4310        let original_path = path.to_path_buf();
4311
4312        let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
4313            let mut rooms = self.active_rooms.lock().unwrap();
4314            let room = rooms
4315                .get_mut(room_id)
4316                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4317            // huddle 0.7.11: read-only joiners (code-joined peers) cannot
4318            // send files. Mirrors the check in send_room_message; without
4319            // it, code-joined peers could broadcast FileOffer/FileChunk
4320            // even though existing members ignore their chat messages.
4321            if room.read_only {
4322                return Err(HuddleError::Other(
4323                    "this room is read-only — you can't send files".into(),
4324                ));
4325            }
4326            if room.info.encrypted {
4327                let crypto = room
4328                    .crypto
4329                    .as_mut()
4330                    .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4331                let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
4332                (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
4333            } else {
4334                (false, None, None, bytes)
4335            }
4336        };
4337        let _ = &mut maybe_session_id; // silence unused warning when non-encrypted
4338
4339        let plan =
4340            self.file_manager
4341                .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
4342        let file_id = plan.file_id.clone();
4343        let total = plan.chunks.len() as u32;
4344        let our_fp = self.identity.fingerprint().to_string();
4345
4346        let attachment = StoredAttachment {
4347            id: 0,
4348            room_id: room_id.to_string(),
4349            message_id: None,
4350            sender_fingerprint: our_fp.clone(),
4351            file_id: file_id.clone(),
4352            name: name.clone(),
4353            mime: mime.clone(),
4354            size_bytes: plan.size_bytes as i64,
4355            status: AttachmentStatus::Ready,
4356            cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
4357            saved_path: Some(original_path.to_string_lossy().into()),
4358            error: None,
4359            encrypted: room_encrypted,
4360            wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
4361            nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
4362            megolm_session_id: encrypted_meta_opt
4363                .as_ref()
4364                .map(|m| m.megolm_session_id.clone()),
4365            content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
4366            created_at: now_unix(),
4367        };
4368        repo::upsert_attachment(&self.db, &attachment)?;
4369        let _ = self.app_event_tx.send(AppEvent::FileOffered {
4370            room_id: room_id.to_string(),
4371            file_id: file_id.clone(),
4372            name: name.clone(),
4373            size_bytes: plan.size_bytes,
4374            sender_fingerprint: our_fp.clone(),
4375        });
4376
4377        // Publish the offer. huddle 0.7.11: FileOffer is now signed so
4378        // peers can't announce a file in someone else's name (attribution
4379        // spoof). FileChunks themselves stay plain — the receiver
4380        // assembles by chunk-index and verifies SHA-256 against
4381        // `file_id`, so spoofed chunks waste bandwidth but can't smuggle
4382        // mismatched bytes through the hash gate.
4383        let offer = RoomMessage::FileOffer {
4384            sender_fingerprint: our_fp.clone(),
4385            file_id: file_id.clone(),
4386            name,
4387            size_bytes: plan.size_bytes,
4388            mime,
4389            chunk_count: total,
4390            encrypted_meta: encrypted_meta_opt,
4391        };
4392        if let Ok(env) = crate::crypto::sign_message(&self.identity, &offer) {
4393            if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4394                self.network
4395                    .publish_room_message(room_id.to_string(), bytes)
4396                    .await;
4397            }
4398        }
4399
4400        // Stream chunks. Brief pacing so gossipsub doesn't see a thundering
4401        // herd from a single peer.
4402        let net = self.network.clone();
4403        let room = room_id.to_string();
4404        let our = our_fp.clone();
4405        let fid = file_id.clone();
4406        let chunks = plan.chunks.clone();
4407        tokio::spawn(async move {
4408            for (i, data) in chunks.iter().enumerate() {
4409                let msg = RoomMessage::FileChunk {
4410                    sender_fingerprint: our.clone(),
4411                    file_id: fid.clone(),
4412                    chunk_index: i as u32,
4413                    total_chunks: total,
4414                    data_b64: B64.encode(data),
4415                };
4416                if let Ok(bytes) = encode_wire(&msg) {
4417                    net.publish_room_message(room.clone(), bytes).await;
4418                }
4419                tokio::time::sleep(Duration::from_millis(40)).await;
4420            }
4421        });
4422
4423        Ok(file_id)
4424    }
4425
4426    /// Save a completed/ready attachment to the user's Downloads folder.
4427    /// Decrypts encrypted attachments on the way out.
4428    pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
4429        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
4430            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
4431        if !matches!(
4432            attachment.status,
4433            AttachmentStatus::Ready | AttachmentStatus::Saved
4434        ) {
4435            return Err(HuddleError::Other(format!(
4436                "attachment is not ready (status={})",
4437                attachment.status.as_str()
4438            )));
4439        }
4440        // Our own encrypted attachment: the file_manager cache holds the
4441        // ciphertext and we have no inbound Megolm session keyed by
4442        // ourselves, so it can't be decrypted back. But `saved_path` still
4443        // points at the original plaintext we sent — copy from there.
4444        let plaintext = if attachment.encrypted
4445            && attachment.sender_fingerprint == self.identity.fingerprint()
4446        {
4447            match attachment
4448                .saved_path
4449                .as_deref()
4450                .filter(|p| Path::new(p).exists())
4451            {
4452                Some(src) => std::fs::read(src)?,
4453                None => {
4454                    return Err(HuddleError::Other(
4455                        "your original file has moved or been deleted — it can't be \
4456                         recovered from the encrypted cache"
4457                            .into(),
4458                    ));
4459                }
4460            }
4461        } else {
4462            let cached = self.file_manager.read_cache(file_id)?;
4463            if attachment.encrypted {
4464                let meta = EncryptedFileMeta {
4465                    megolm_session_id: attachment
4466                        .megolm_session_id
4467                        .clone()
4468                        .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
4469                    wrapped_key_b64: attachment
4470                        .wrapped_key
4471                        .clone()
4472                        .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
4473                    nonce_b64: attachment
4474                        .nonce
4475                        .clone()
4476                        .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
4477                    content_hash: attachment
4478                        .content_hash
4479                        .clone()
4480                        .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
4481                };
4482                self.decrypt_attachment(
4483                    room_id,
4484                    &attachment.sender_fingerprint,
4485                    &cached,
4486                    &meta,
4487                )?
4488            } else {
4489                cached
4490            }
4491        };
4492        let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
4493        repo::update_attachment_paths(
4494            &self.db,
4495            room_id,
4496            file_id,
4497            None,
4498            Some(&saved.to_string_lossy()),
4499        )?;
4500        repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
4501        let _ = self.app_event_tx.send(AppEvent::FileSaved {
4502            file_id: file_id.into(),
4503            path: saved.to_string_lossy().into(),
4504        });
4505        Ok(saved)
4506    }
4507
4508    /// Drop any in-flight chunks and remove the attachment row.
4509    pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
4510        self.file_manager.cancel_incoming(file_id);
4511        repo::update_attachment_status(
4512            &self.db,
4513            room_id,
4514            file_id,
4515            AttachmentStatus::Cancelled,
4516            None,
4517        )?;
4518        Ok(())
4519    }
4520
4521    /// Launch the system's default opener on a saved file.
4522    pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
4523        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
4524            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
4525        let path = attachment
4526            .saved_path
4527            .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
4528        open_with_system(&path)
4529    }
4530
4531    pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
4532        repo::list_room_attachments(&self.db, room_id)
4533    }
4534
4535    /// Mark a peer's fingerprint as verified in the given room. Used by
4536    /// the `^V` verification modal after the user has compared the
4537    /// fingerprint out-of-band.
4538    pub fn set_member_verified(
4539        &self,
4540        room_id: &str,
4541        fingerprint: &str,
4542        verified: bool,
4543    ) -> Result<()> {
4544        // Make sure there's a member row to flip — peer_id is unknown
4545        // at this layer when the user verifies an out-of-band identity,
4546        // so we use the fingerprint as the canonical identity key with
4547        // an empty peer_id placeholder if none exists.
4548        let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
4549        if !members.iter().any(|m| m.fingerprint == fingerprint) {
4550            repo::upsert_room_member(
4551                &self.db,
4552                &StoredRoomMember {
4553                    room_id: room_id.to_string(),
4554                    peer_id: String::new(),
4555                    fingerprint: fingerprint.to_string(),
4556                    last_seen: Some(now_unix()),
4557                    verified,
4558                    ed25519_pubkey: None,
4559                    role: "member".into(),
4560                },
4561            )?;
4562        }
4563        repo::set_member_verified(&self.db, room_id, fingerprint, verified)
4564    }
4565
4566    pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
4567        repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
4568    }
4569
4570    /// Phase B: is `fingerprint` an owner of `room_id`? Used by the TUI
4571    /// to gate `^K` / `^G` and the kick/grant member-picker actions.
4572    pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
4573        repo::list_room_owners(&self.db, room_id)
4574            .unwrap_or_default()
4575            .iter()
4576            .any(|fp| fp == fingerprint)
4577    }
4578
4579    pub fn we_are_owner(&self, room_id: &str) -> bool {
4580        self.is_owner(room_id, &self.identity.fingerprint().to_string())
4581    }
4582
4583    /// Phase B: list current owner fingerprints for `room_id` — used to
4584    /// render an owner badge in the member panel.
4585    pub fn room_owners(&self, room_id: &str) -> Vec<String> {
4586        repo::list_room_owners(&self.db, room_id).unwrap_or_default()
4587    }
4588
4589    /// huddle 0.7.6: true iff this session was started with a master
4590    /// passphrase. The TUI uses this to pick the Go Dark gate — passphrase
4591    /// if available (the natural strong secret the user already knows),
4592    /// else the typed `DELETE EVERYTHING` phrase since no-master-passphrase
4593    /// sessions have nothing else to compare against.
4594    pub fn has_master_passphrase(&self) -> bool {
4595        self.session_persist_key != [0u8; 32]
4596    }
4597
4598    /// Phase E: global toggle — when true, inbound dials from
4599    /// unverified fingerprints are auto-rejected without prompting.
4600    pub fn verified_only_inbound(&self) -> bool {
4601        repo::get_setting(&self.db, "verified_only_inbound")
4602            .unwrap_or(None)
4603            .map(|v| v == "1")
4604            .unwrap_or(false)
4605    }
4606
4607    pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
4608        repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
4609    }
4610
4611    /// huddle 0.7.8: persisted LAN-discovery toggle. When true, the next
4612    /// launch starts in `NetworkMode::Mdns` so the device joins LAN mDNS
4613    /// announcements **alongside** the onion relay (both transports run
4614    /// together). When false, the next launch starts relay-only
4615    /// (`NetworkMode::Server`).
4616    ///
4617    /// huddle 0.9.2: default **OFF** (was ON pre-onion-relay) — the
4618    /// relay-only `Server` mode is the 0.8+ baseline, so the toggle is a
4619    /// true opt-in. Restart required to apply (a live `Toggle<Mdns>` flip
4620    /// would require rebuilding the libp2p behaviour).
4621    pub fn mdns_enabled(&self) -> bool {
4622        repo::get_setting(&self.db, "mdns_enabled")
4623            .unwrap_or(None)
4624            .map(|v| v == "1")
4625            .unwrap_or(false)
4626    }
4627
4628    pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
4629        repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
4630    }
4631
4632    /// huddle 1.1.3: the persisted theme — `"system"` (default; the GUI follows
4633    /// the OS light/dark setting), `"dark"`, or `"light"`. The desktop GUI reads
4634    /// this to pick its egui visuals. huddle 1.1.4: the TUI now honors it too
4635    /// (`"dark"`/`"light"`; `"system"` resolves to Dark there). Unset resolves to
4636    /// `"system"`; installs that already persisted `"dark"`/`"light"` keep them.
4637    pub fn theme(&self) -> String {
4638        repo::get_setting(&self.db, "theme")
4639            .ok()
4640            .flatten()
4641            .filter(|s| !s.trim().is_empty())
4642            .unwrap_or_else(|| "system".to_string())
4643    }
4644
4645    /// huddle 1.1.4: the resolved Tor SOCKS5 proxy address (e.g.
4646    /// `127.0.0.1:9050`). Lets privacy-sensitive clearnet fetches (the
4647    /// opt-in update check) tunnel through Tor rather than leak the IP.
4648    pub fn tor_socks(&self) -> &str {
4649        &self.tor_socks
4650    }
4651
4652    pub fn set_theme(&self, theme: &str) -> Result<()> {
4653        repo::set_setting(&self.db, "theme", theme)
4654    }
4655
4656    /// huddle 1.0: the persisted clearnet relay URL (a `ws://<ip>:<port>/ws`
4657    /// or `wss://host/ws` door onto the relay backend — e.g. a cloudflared
4658    /// tunnel). `None` when unset/blank. This is what the GUI "Set relay" field
4659    /// writes and what [`Self::set_clearnet_relay`] manages; the startup
4660    /// resolution in `start_with_db_and_options` reads it as the lowest-
4661    /// precedence source (CLI → config.toml → this).
4662    pub fn clearnet_relay(&self) -> Option<String> {
4663        repo::get_setting(&self.db, "clearnet_url")
4664            .unwrap_or(None)
4665            .filter(|s| !s.trim().is_empty())
4666    }
4667
4668    /// huddle 1.0: persist (or clear) the clearnet relay URL and bias the
4669    /// transport order so it's tried first.
4670    ///
4671    /// `Some(url)` saves the URL AND pins a clearnet-first door order so the
4672    /// app connects straight to the clearnet relay without paying the onion
4673    /// connect timeout each reconnect cycle (the point of "my VPS, no Tor").
4674    /// `None` (or a blank url) clears both, restoring the default
4675    /// most-private-first order. Takes effect on the next launch — mirrors the
4676    /// mDNS toggle, since the door order is resolved once at startup.
4677    pub fn set_clearnet_relay(&self, url: Option<&str>) -> Result<()> {
4678        match url.map(str::trim).filter(|s| !s.is_empty()) {
4679            Some(u) => {
4680                repo::set_setting(&self.db, "clearnet_url", u)?;
4681                // Clearnet doors first so a no-Tor user connects immediately;
4682                // onion doors stay in the list as fallback.
4683                repo::set_setting(
4684                    &self.db,
4685                    "transport_order",
4686                    "clearnet-wss,clearnet-ws,onion-tor,onion-bridge,onion-arti",
4687                )
4688            }
4689            None => {
4690                repo::set_setting(&self.db, "clearnet_url", "")?;
4691                // Empty → resolution falls back to the default fallback order.
4692                repo::set_setting(&self.db, "transport_order", "")
4693            }
4694        }
4695    }
4696
4697    /// huddle 0.7.8: persisted desktop-notification opt-out. The
4698    /// notifier itself is a local-only `osascript`/`notify-send`
4699    /// process call — toggling this OFF skips the call entirely so
4700    /// nothing reaches the OS notification daemon. Default ON to
4701    /// preserve current behavior.
4702    pub fn notifications_enabled(&self) -> bool {
4703        repo::get_setting(&self.db, "notifications_enabled")
4704            .unwrap_or(None)
4705            .map(|v| v == "1")
4706            .unwrap_or(true)
4707    }
4708
4709    pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
4710        repo::set_setting(
4711            &self.db,
4712            "notifications_enabled",
4713            if on { "1" } else { "0" },
4714        )
4715    }
4716
4717    /// huddle 0.7.8: stable 12-hex Safety Code derived from our Ed25519
4718    /// pubkey. Display-only; used as a quick visual fingerprint match in
4719    /// Profile / Account. SAS-via-emoji remains the actual verification
4720    /// primitive.
4721    pub fn safety_code(&self) -> String {
4722        crate::identity::safety_code(&self.identity.public_bytes())
4723    }
4724
4725    /// Phase E: per-room verified-only-join. When true, the host (and
4726    /// every honest existing member) drops MemberAnnounce from joiners
4727    /// who aren't globally SAS-verified, and the lowest-fp owner sends
4728    /// back a signed `JoinRefused` so the joiner sees an explanation.
4729    pub fn room_verified_only(&self, room_id: &str) -> bool {
4730        repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
4731    }
4732
4733    pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
4734        repo::set_room_verified_only(&self.db, room_id, on)
4735    }
4736
4737    /// Phase H: first-launch onboarding flag.
4738    pub fn onboarding_seen(&self) -> bool {
4739        repo::is_onboarding_seen(&self.db).unwrap_or(true)
4740    }
4741
4742    pub fn mark_onboarding_seen(&self) -> Result<()> {
4743        repo::mark_onboarding_seen(&self.db)
4744    }
4745
4746    /// huddle 0.6: version string of huddle the user last finished
4747    /// onboarding for. Compared against `env!("CARGO_PKG_VERSION")` at
4748    /// startup so a version bump re-fires the "what's new" card.
4749    pub fn last_seen_onboarding_version(&self) -> Option<String> {
4750        repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
4751    }
4752
4753    pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
4754        repo::set_last_seen_onboarding_version(&self.db, version)
4755    }
4756
4757    /// huddle 0.6: opt-in flag for the crates.io update check.
4758    /// `None` ⇒ the user hasn't been asked yet.
4759    pub fn update_check_enabled(&self) -> Option<bool> {
4760        repo::get_update_check_enabled(&self.db).unwrap_or(None)
4761    }
4762
4763    pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
4764        repo::set_update_check_enabled(&self.db, enabled)
4765    }
4766
4767    /// huddle 0.6: cache anchor for the once-per-24h crates.io poll.
4768    /// Returns 0 if nothing has been recorded yet.
4769    pub fn last_update_check_at(&self) -> i64 {
4770        repo::get_setting(&self.db, "last_update_check_at")
4771            .ok()
4772            .flatten()
4773            .and_then(|s| s.parse().ok())
4774            .unwrap_or(0)
4775    }
4776
4777    pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
4778        repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
4779    }
4780
4781    /// huddle 0.6: the most recent `max_stable_version` we saw on
4782    /// crates.io. Persisted so a re-launch within the 24h window
4783    /// can render the banner without re-fetching.
4784    pub fn last_known_remote_version(&self) -> Option<String> {
4785        repo::get_setting(&self.db, "last_known_remote_version")
4786            .ok()
4787            .flatten()
4788    }
4789
4790    pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
4791        repo::set_setting(&self.db, "last_known_remote_version", v)
4792    }
4793
4794    /// Phase B: promote `target_fingerprint` to owner. Builds a signed
4795    /// `OwnerGrant`, broadcasts it, and applies it locally. Returns an
4796    /// error if we ourselves aren't an owner — only owners can grant.
4797    pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
4798        let our_fp = self.identity.fingerprint().to_string();
4799        if !self.is_owner(room_id, &our_fp) {
4800            return Err(HuddleError::Other(
4801                "only an owner can grant owner".into(),
4802            ));
4803        }
4804        let msg = RoomMessage::OwnerGrant {
4805            room_id: room_id.to_string(),
4806            target_fingerprint: target_fingerprint.to_string(),
4807        };
4808        let env = crate::crypto::sign_message(&self.identity, &msg)?;
4809        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4810        self.network
4811            .publish_room_message(room_id.to_string(), bytes)
4812            .await;
4813        // Apply locally too — peers will converge on the next announce.
4814        repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
4815        Ok(())
4816    }
4817
4818    /// Phase B: kick `target_fingerprint` from `room_id`. Broadcasts a
4819    /// signed `BanMember`, records the ban locally, then immediately
4820    /// rotates the room key under a freshly-generated passphrase. Returns
4821    /// the new passphrase so the caller can show it to the owner for
4822    /// out-of-band sharing with remaining members.
4823    ///
4824    /// The rotation is the cryptographic enforcement: a banned peer can
4825    /// still subscribe to the gossipsub topic and see the ciphertext,
4826    /// but they can't unwrap the new session key without the new
4827    /// passphrase, so they can't decrypt anything sent after the kick.
4828    pub async fn kick_member(
4829        &self,
4830        room_id: &str,
4831        target_fingerprint: &str,
4832    ) -> Result<String> {
4833        let our_fp = self.identity.fingerprint().to_string();
4834        if !self.is_owner(room_id, &our_fp) {
4835            return Err(HuddleError::Other("only an owner can kick".into()));
4836        }
4837        if target_fingerprint == our_fp {
4838            return Err(HuddleError::Other("can't kick yourself".into()));
4839        }
4840        let info = self
4841            .active_rooms
4842            .lock()
4843            .unwrap()
4844            .get(room_id)
4845            .map(|r| r.info.clone())
4846            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4847        if !info.encrypted {
4848            // Without a key to rotate, a "kick" is purely advisory —
4849            // ban only. Honest clients drop their messages, but anyone
4850            // can still read the room. Honest in v1; documented.
4851            let msg = RoomMessage::BanMember {
4852                room_id: room_id.to_string(),
4853                target_fingerprint: target_fingerprint.to_string(),
4854            };
4855            let env = crate::crypto::sign_message(&self.identity, &msg)?;
4856            let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4857            self.network
4858                .publish_room_message(room_id.to_string(), bytes)
4859                .await;
4860            repo::add_room_ban(
4861                &self.db,
4862                room_id,
4863                target_fingerprint,
4864                &our_fp,
4865                &env.signature_b64,
4866                now_unix(),
4867            )?;
4868            self.evict_banned_member(room_id, target_fingerprint);
4869            return Ok(String::new());
4870        }
4871        // Encrypted room — full kick path.
4872        let new_passphrase = generate_join_passphrase();
4873        let msg = RoomMessage::BanMember {
4874            room_id: room_id.to_string(),
4875            target_fingerprint: target_fingerprint.to_string(),
4876        };
4877        let env = crate::crypto::sign_message(&self.identity, &msg)?;
4878        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4879        self.network
4880            .publish_room_message(room_id.to_string(), bytes)
4881            .await;
4882        repo::add_room_ban(
4883            &self.db,
4884            room_id,
4885            target_fingerprint,
4886            &our_fp,
4887            &env.signature_b64,
4888            now_unix(),
4889        )?;
4890        self.evict_banned_member(room_id, target_fingerprint);
4891        // Reuse the existing rotation flow so all the existing salt /
4892        // session / persistence logic stays in one place.
4893        self.rotate_room(room_id, &new_passphrase).await?;
4894        Ok(new_passphrase)
4895    }
4896
4897    /// Phase F: generate an 8-char alphanumeric join code for `room_id`,
4898    /// good for 10 minutes. Stored in memory only on the issuing owner's
4899    /// machine — a single use clears it. Caller is responsible for
4900    /// sharing the code OOB with the prospective joiner.
4901    ///
4902    /// Owner-only. Errors if `room_id` isn't active or we're not an owner.
4903    pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
4904        let our_fp = self.identity.fingerprint().to_string();
4905        if !self.is_owner(room_id, &our_fp) {
4906            return Err(HuddleError::Other(
4907                "only an owner can issue join codes".into(),
4908            ));
4909        }
4910        let code = generate_alphanumeric_code(8);
4911        let expires_at = now_unix() + 10 * 60;
4912        let mut rooms = self.active_rooms.lock().unwrap();
4913        let room = rooms
4914            .get_mut(room_id)
4915            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4916        // Prune expired entries while we're here so the list doesn't grow.
4917        let now = now_unix();
4918        room.issued_codes.retain(|(_, exp)| *exp > now);
4919        room.issued_codes.push((code.clone(), expires_at));
4920        Ok(code)
4921    }
4922
4923    /// Phase F: join `room_id` using a short-lived code instead of the
4924    /// passphrase. Generates an ephemeral X25519 keypair, broadcasts a
4925    /// signed `CodeJoinRequest`, and waits for the owner's
4926    /// `CodeJoinResponse`. The receive arm builds an `ActiveRoom`
4927    /// flagged read-only (no passphrase = can't share our outbound
4928    /// session key with others).
4929    pub async fn join_room_with_code(
4930        &self,
4931        room_id: &str,
4932        code: &str,
4933    ) -> Result<()> {
4934        // Resolve discovered metadata so we know name/encrypted/etc.
4935        let info = {
4936            let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
4937            match d {
4938                Some(d) => StoredRoom {
4939                    id: room_id.to_string(),
4940                    name: d.name,
4941                    creator_fingerprint: d.creator_fingerprint,
4942                    encrypted: d.encrypted,
4943                    passphrase_salt: None, // unused on code-join path
4944                    created_at: now_unix(),
4945                    last_active: Some(now_unix()),
4946                    // huddle 0.7: code-join is groups-only by design — DMs
4947                    // are 1-1 and don't use the code flow.
4948                    kind: d.kind,
4949                },
4950                None => {
4951                    return Err(HuddleError::Other(format!(
4952                        "room {room_id} not visible — wait for an announcement"
4953                    )))
4954                }
4955            }
4956        };
4957        if !info.encrypted {
4958            return Err(HuddleError::Other(
4959                "code-join only applies to encrypted rooms".into(),
4960            ));
4961        }
4962        let our_fp = self.identity.fingerprint().to_string();
4963        // Generate ephemeral X25519 keypair; remember the secret so the
4964        // CodeJoinResponse receive arm can complete ECDH on this peer.
4965        use x25519_dalek::{PublicKey, StaticSecret};
4966        let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
4967        let our_pub = PublicKey::from(&our_secret);
4968        // Stash the secret keyed by (room_id, our_fp); the response
4969        // handler removes the matching entry when a response targeted
4970        // at us arrives. The composite key means a second joiner can
4971        // be in flight in the same room without overwriting our state.
4972        let key = (room_id.to_string(), our_fp.clone());
4973        self.pending_code_secrets
4974            .lock()
4975            .unwrap()
4976            .insert(key.clone(), our_secret);
4977        // Code-join timeout: if no response in 30s, the entry will
4978        // still be in the map (the response handler removes it on
4979        // success). Surface a `CodeJoinTimedOut` to the TUI so the
4980        // user isn't stuck staring at an empty room expecting traffic.
4981        let map = self.pending_code_secrets.clone();
4982        let tx = self.app_event_tx.clone();
4983        let timeout_room = room_id.to_string();
4984        tokio::spawn(async move {
4985            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
4986            let still_pending = map.lock().unwrap().remove(&key).is_some();
4987            if still_pending {
4988                let _ = tx.send(AppEvent::CodeJoinTimedOut {
4989                    room_id: timeout_room,
4990                    reason: "no response from owner — code may be wrong or expired".into(),
4991                });
4992            }
4993        });
4994        // Persist the rooms row BEFORE constructing RoomCrypto, whose
4995        // `persist_outbound()` writes a `room_megolm_sessions` row with
4996        // a FK to `rooms(id)`. Without this, the FK fires and the
4997        // join aborts. The salt is left None for now — we don't have
4998        // the passphrase and the announcing peer's salt is cached in
4999        // ROOM_SALT_CACHE for whenever we get re-onboarded.
5000        repo::insert_room(&self.db, &info)?;
5001        // Create a placeholder ActiveRoom with no crypto yet; we'll
5002        // fill in the inbound session in the response handler.
5003        self.active_rooms.lock().unwrap().insert(
5004            room_id.to_string(),
5005            ActiveRoom {
5006                info: info.clone(),
5007                crypto: Some(RoomCrypto::new_for_room(
5008                    self.db.clone(),
5009                    room_id.to_string(),
5010                    our_fp.clone(),
5011                    self.session_persist_key,
5012                )?),
5013                passphrase_key: None,
5014                members: {
5015                    let mut s = HashSet::new();
5016                    s.insert(our_fp.clone());
5017                    s
5018                },
5019                typers: HashMap::new(),
5020                read_only: true,
5021                issued_codes: Vec::new(),
5022            },
5023        );
5024        self.network.subscribe_room(room_id.to_string()).await;
5025        // Broadcast the request.
5026        let req = RoomMessage::CodeJoinRequest {
5027            room_id: room_id.to_string(),
5028            joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
5029            code: code.to_string(),
5030        };
5031        let env = crate::crypto::sign_message(&self.identity, &req)?;
5032        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5033        self.network
5034            .publish_room_message(room_id.to_string(), bytes)
5035            .await;
5036        // Emit RoomJoined so the TUI opens the tab. Subsequent ability
5037        // to read messages depends on receiving the owner's response.
5038        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
5039            room_id: room_id.to_string(),
5040        });
5041        Ok(())
5042    }
5043
5044    /// Phase G: start an SAS verification with `target_fingerprint` in
5045    /// `room_id`. Returns the tx_id so the caller can correlate
5046    /// subsequent events. The full flow is asynchronous — the partner
5047    /// must accept on their end, both compute the ECDH-derived SAS
5048    /// code, OOB-compare it, and each press Match.
5049    pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
5050        let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
5051        let tx_id = B64.encode(tx_id_bytes);
5052        let msg = RoomMessage::SasInit {
5053            tx_id: tx_id.clone(),
5054            ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
5055            target_fingerprint: target_fingerprint.to_string(),
5056        };
5057        let env = crate::crypto::sign_message(&self.identity, &msg)?;
5058        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5059        self.sas_flows.lock().unwrap().insert(
5060            tx_id.clone(),
5061            SasFlow {
5062                room_id: room_id.to_string(),
5063                partner_fingerprint: target_fingerprint.to_string(),
5064                our_secret,
5065                sas_code: None,
5066                our_confirmed: false,
5067                their_confirmed: false,
5068                finalized: false,
5069            },
5070        );
5071        self.network
5072            .publish_room_message(room_id.to_string(), bytes)
5073            .await;
5074        Ok(tx_id)
5075    }
5076
5077    /// Phase G: user pressed Match on the SAS code modal — broadcast our
5078    /// signed `SasConfirm{matched: true}`. If the partner has already
5079    /// matched, this completes verification on both sides.
5080    pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
5081        let (room_id, partner_fp, both_done) = {
5082            let mut flows = self.sas_flows.lock().unwrap();
5083            let flow = flows
5084                .get_mut(tx_id)
5085                .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
5086            flow.our_confirmed = true;
5087            // huddle 0.7.11: latch finalize so the inbound SasConfirm
5088            // handler won't fire `finish_sas` a second time. See
5089            // SasConfirm arm for the symmetric guard.
5090            let do_finish = flow.our_confirmed && flow.their_confirmed && !flow.finalized;
5091            if do_finish {
5092                flow.finalized = true;
5093            }
5094            (
5095                flow.room_id.clone(),
5096                flow.partner_fingerprint.clone(),
5097                do_finish,
5098            )
5099        };
5100        let msg = RoomMessage::SasConfirm {
5101            tx_id: tx_id.to_string(),
5102            matched: true,
5103        };
5104        let env = crate::crypto::sign_message(&self.identity, &msg)?;
5105        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5106        self.network
5107            .publish_room_message(room_id.clone(), bytes)
5108            .await;
5109        if both_done {
5110            self.finish_sas(tx_id, &room_id, &partner_fp).await?;
5111        }
5112        Ok(())
5113    }
5114
5115    /// Phase G: cancel an in-flight SAS — drop our local state. Doesn't
5116    /// broadcast a "matched=false" notice in v1 (partner's flow stays
5117    /// dangling; they can cancel their side too). Quiet teardown.
5118    pub fn sas_cancel(&self, tx_id: &str) {
5119        self.sas_flows.lock().unwrap().remove(tx_id);
5120    }
5121
5122    /// Phase G internal: both sides have confirmed — flip the partner's
5123    /// fingerprint to verified (per-room AND global) and clean up.
5124    async fn finish_sas(
5125        &self,
5126        tx_id: &str,
5127        room_id: &str,
5128        partner_fingerprint: &str,
5129    ) -> Result<()> {
5130        repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
5131        repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
5132        self.sas_flows.lock().unwrap().remove(tx_id);
5133        let _ = self.app_event_tx.send(AppEvent::SasVerified {
5134            room_id: room_id.to_string(),
5135            partner_fingerprint: partner_fingerprint.to_string(),
5136        });
5137        Ok(())
5138    }
5139
5140    /// Phase B internal: drop a banned member's in-memory presence in a
5141    /// room. Persistent ban already went to `room_bans`. Called from
5142    /// `kick_member` (locally banning ourselves) and from the
5143    /// `RoomMessage::BanMember` receive arm (peer-initiated ban).
5144    fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
5145        if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
5146            room.members.remove(fingerprint);
5147        }
5148        let _ = self.app_event_tx.send(AppEvent::MemberLeft {
5149            room_id: room_id.to_string(),
5150            fingerprint: fingerprint.to_string(),
5151        });
5152    }
5153
5154    pub fn display_name(&self) -> Option<String> {
5155        repo::get_display_name(&self.db).unwrap_or(None)
5156    }
5157
5158    pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
5159        repo::set_display_name(&self.db, name)
5160    }
5161
5162    /// huddle 0.5: set the local user's self-declared username (or clear
5163    /// it with None) and broadcast a signed `ProfileUpdate` to every
5164    /// joined room. Receivers cache the latest per-fingerprint username
5165    /// in `peer_profiles`; unsigned envelopes are dropped at the receive
5166    /// arm so the username can't be spoofed.
5167    pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
5168        repo::set_display_name(&self.db, name)?;
5169        let msg = RoomMessage::ProfileUpdate {
5170            sender_fingerprint: self.identity.fingerprint().to_string(),
5171            username: name.map(|s| s.to_string()),
5172            updated_at: now_unix_ms(),
5173        };
5174        let env = crate::crypto::sign_message(&self.identity, &msg)?;
5175        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5176        let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
5177        for room_id in rooms {
5178            self.network
5179                .publish_room_message(room_id, bytes.clone())
5180                .await;
5181        }
5182        Ok(())
5183    }
5184
5185    /// huddle 0.5: cached username for a peer (any peer we've ever
5186    /// received a signed `ProfileUpdate` from), or None if unknown or
5187    /// the peer cleared their username. Callers render `[anonymous]` on
5188    /// None.
5189    pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
5190        repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
5191    }
5192
5193    /// Look up the display name we've seen for a peer. Forwards to
5194    /// `lookup_username` (the new signed-source-of-truth) so existing
5195    /// call sites get the authenticated value without churn.
5196    pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
5197        self.lookup_username(fingerprint)
5198    }
5199
5200    /// huddle 0.7.12: reverse of `lookup_username` — every fingerprint
5201    /// that has broadcast `username` via a signed `ProfileUpdate`.
5202    /// Usernames aren't unique, so callers must handle 0 / 1 / many.
5203    /// Backs the Compose-DM resolver so typing a contact's name opens a
5204    /// DM over the existing mesh instead of falling through to a fresh
5205    /// dial (matching the resolution `dial_by_id_or_username` already
5206    /// does for the add-friend flow).
5207    pub fn peers_with_username(&self, username: &str) -> Vec<String> {
5208        repo::find_peers_by_username(&self.db, username).unwrap_or_default()
5209    }
5210
5211    pub fn is_room_muted(&self, room_id: &str) -> bool {
5212        repo::is_room_muted(&self.db, room_id).unwrap_or(false)
5213    }
5214
5215    /// Phase B: list the fingerprints currently banned from a room
5216    /// (newest first). Backs the `^B` in-room view; intended for
5217    /// owners but the read itself is harmless and we let callers
5218    /// gate via `we_are_owner` if they want owner-only display.
5219    pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
5220        repo::list_room_bans(&self.db, room_id).unwrap_or_default()
5221    }
5222
5223    /// Phase A: list every globally-blocked peer (one fingerprint per
5224    /// row). Surfaced in the Settings modal alongside a clear-all
5225    /// action that calls `unblock_peer` in a loop.
5226    /// huddle 0.7: every globally SAS-verified peer. Surfaced in the
5227    /// People pane's "Verified" sub-list.
5228    pub fn list_verified_peers(&self) -> Vec<String> {
5229        repo::list_verified_peers(&self.db).unwrap_or_default()
5230    }
5231
5232    pub fn list_blocked_peers(&self) -> Vec<String> {
5233        repo::list_blocked_peers(&self.db).unwrap_or_default()
5234    }
5235
5236    /// Phase A: remove `fingerprint` from the persistent blocklist. The
5237    /// peer will no longer be auto-rejected on connection; they fall
5238    /// back to the regular inbound-dial accept/reject prompt.
5239    pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
5240        repo::unblock_peer(&self.db, fingerprint)
5241    }
5242
5243    /// huddle 0.7: add `fingerprint` to the persistent blocklist. Used
5244    /// by the People pane's per-row "block" action. Subsequent inbound
5245    /// dials from this fingerprint are auto-rejected without prompting.
5246    pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
5247        repo::block_peer(&self.db, fingerprint, now_unix())
5248    }
5249
5250    /// Phase F: rooms entered via a join code don't have the passphrase
5251    /// in memory, so the joining peer can't wrap their own outbound
5252    /// session key for newer members — they can read and send, they
5253    /// just can't onboard others. The TUI renders a `(read-only)`
5254    /// badge in the room tab so the user understands.
5255    pub fn is_room_read_only(&self, room_id: &str) -> bool {
5256        self.active_rooms
5257            .lock()
5258            .unwrap()
5259            .get(room_id)
5260            .map(|r| r.read_only)
5261            .unwrap_or(false)
5262    }
5263
5264    pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
5265        repo::set_room_muted(&self.db, room_id, muted)
5266    }
5267
5268    /// Broadcast a "I'm typing" pulse to the given room. Caller is
5269    /// responsible for debouncing (don't fire more than every ~500ms).
5270    pub async fn broadcast_typing(&self, room_id: &str) {
5271        if !self.active_rooms.lock().unwrap().contains_key(room_id) {
5272            return;
5273        }
5274        let msg = RoomMessage::Typing {
5275            sender_fingerprint: self.identity.fingerprint().to_string(),
5276        };
5277        if let Ok(bytes) = encode_wire(&msg) {
5278            self.network
5279                .publish_room_message(room_id.to_string(), bytes)
5280                .await;
5281        }
5282    }
5283
5284    /// Returns the fingerprints of peers currently typing in `room_id`,
5285    /// pruning entries past their TTL.
5286    pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
5287        let now = now_unix();
5288        let mut rooms = self.active_rooms.lock().unwrap();
5289        let room = match rooms.get_mut(room_id) {
5290            Some(r) => r,
5291            None => return Vec::new(),
5292        };
5293        room.typers.retain(|_, exp| *exp > now);
5294        let mut v: Vec<String> = room.typers.keys().cloned().collect();
5295        v.sort();
5296        v
5297    }
5298
5299    // -------------------------------------------------------------------
5300    // Room key rotation
5301    // -------------------------------------------------------------------
5302
5303    /// Rotate this room's outbound Megolm session under a fresh
5304    /// passphrase. Broadcasts `RotateRoomKey` (so other members know to
5305    /// expect a new passphrase) and a fresh `MemberAnnounce` with the
5306    /// new wrapped session key. Old inbound sessions stay in storage
5307    /// for decrypting historic messages.
5308    pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
5309        if new_passphrase.is_empty() {
5310            return Err(HuddleError::Other("new passphrase is empty".into()));
5311        }
5312        let new_salt = passphrase::random_salt();
5313        let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
5314
5315        let info = {
5316            let mut rooms = self.active_rooms.lock().unwrap();
5317            let room = rooms
5318                .get_mut(room_id)
5319                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
5320            if !room.info.encrypted {
5321                return Err(HuddleError::Other(
5322                    "rotation only applies to encrypted rooms".into(),
5323                ));
5324            }
5325            // Generate a fresh outbound Megolm session for this member.
5326            let new_crypto = RoomCrypto::new_for_room(
5327                self.db.clone(),
5328                room_id.to_string(),
5329                self.identity.fingerprint().to_string(),
5330                self.session_persist_key,
5331            )?;
5332            room.crypto = Some(new_crypto);
5333            room.passphrase_key = Some(new_key);
5334            room.info.passphrase_salt = Some(new_salt.to_vec());
5335            room.info.clone()
5336        };
5337
5338        // Broadcast before persisting: peers learn about the rotation even
5339        // if we crash before the DB write lands, and our own restore path
5340        // can recover from the persisted Megolm session plus the announced
5341        // salt. Persisting first would risk a DB row that's ahead of what
5342        // any peer knows.
5343        let rot = RoomMessage::RotateRoomKey {
5344            rotator_fingerprint: self.identity.fingerprint().to_string(),
5345            new_salt: new_salt.to_vec(),
5346        };
5347        // Signed: rotations are self-attested, so peers can prove the
5348        // claimed `rotator_fingerprint` really came from that identity.
5349        // An unsigned rotation is rejected on the receive side.
5350        if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
5351            if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
5352                self.network
5353                    .publish_room_message(room_id.to_string(), bytes)
5354                    .await;
5355            }
5356        }
5357        // Re-announce ourselves with the new wrapped session key.
5358        if let Err(e) = self.broadcast_member_announce(room_id).await {
5359            warn!(%e, "rotate: broadcast announce failed");
5360        }
5361
5362        // Now persist the new salt on the stored row.
5363        repo::insert_room(&self.db, &info)?;
5364        Ok(())
5365    }
5366
5367    /// Used by the TUI when another member rotates a room we're in.
5368    /// Derives the new key, updates our local state, and re-announces
5369    /// so the rotator can share their fresh outbound session with us.
5370    pub async fn accept_rotation(
5371        &self,
5372        room_id: &str,
5373        new_salt: &[u8],
5374        new_passphrase: &str,
5375    ) -> Result<()> {
5376        let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
5377        let info = {
5378            let mut rooms = self.active_rooms.lock().unwrap();
5379            let room = rooms
5380                .get_mut(room_id)
5381                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
5382            room.passphrase_key = Some(new_key);
5383            room.info.passphrase_salt = Some(new_salt.to_vec());
5384            room.info.clone()
5385        };
5386        // Ask the rotator (and anyone) to re-share their session key
5387        // before persisting, so a crash before the DB write still leaves
5388        // peers aware we've moved to the new salt.
5389        let req = RoomMessage::SessionKeyRequest {
5390            requester_fingerprint: self.identity.fingerprint().to_string(),
5391        };
5392        if let Ok(bytes) = encode_wire(&req) {
5393            self.network
5394                .publish_room_message(room_id.to_string(), bytes)
5395                .await;
5396        }
5397        repo::insert_room(&self.db, &info)?;
5398        Ok(())
5399    }
5400
5401    // -------------------------------------------------------------------
5402    // File transfer — internal handlers
5403    // -------------------------------------------------------------------
5404
5405    #[allow(clippy::too_many_arguments)]
5406    fn handle_file_offer(
5407        &self,
5408        room_id: &str,
5409        sender_fingerprint: String,
5410        file_id: String,
5411        name: String,
5412        size_bytes: u64,
5413        mime: Option<String>,
5414        _chunk_count: u32,
5415        encrypted_meta: Option<EncryptedFileMeta>,
5416    ) {
5417        let encrypted = encrypted_meta.is_some();
5418        let attachment = StoredAttachment {
5419            id: 0,
5420            room_id: room_id.to_string(),
5421            message_id: None,
5422            sender_fingerprint: sender_fingerprint.clone(),
5423            file_id: file_id.clone(),
5424            name: name.clone(),
5425            mime,
5426            size_bytes: size_bytes as i64,
5427            status: AttachmentStatus::Offered,
5428            cache_path: None,
5429            saved_path: None,
5430            error: None,
5431            encrypted,
5432            wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
5433            nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
5434            megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
5435            content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
5436            created_at: now_unix(),
5437        };
5438        if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
5439            warn!(%e, "upsert attachment");
5440            return;
5441        }
5442        // If chunks started arriving before this offer, the transfer's
5443        // size denominator was a guess — correct it with the real size.
5444        self.file_manager.set_expected_size(&file_id, size_bytes);
5445        let _ = self.app_event_tx.send(AppEvent::FileOffered {
5446            room_id: room_id.to_string(),
5447            file_id,
5448            name,
5449            size_bytes,
5450            sender_fingerprint,
5451        });
5452    }
5453
5454    fn handle_file_chunk(
5455        &self,
5456        room_id: &str,
5457        _sender_fingerprint: String,
5458        file_id: String,
5459        chunk_index: u32,
5460        total_chunks: u32,
5461        data_b64: String,
5462    ) {
5463        let data = match B64.decode(&data_b64) {
5464            Ok(d) => d,
5465            Err(e) => {
5466                warn!(%e, "bad chunk base64");
5467                return;
5468            }
5469        };
5470        // Pull the announced size + lifecycle state from our stored offer.
5471        // A terminal-state row means the user cancelled or the transfer
5472        // already failed — late chunks must not resurrect it.
5473        let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
5474            Ok(Some(a)) => {
5475                if matches!(
5476                    a.status,
5477                    AttachmentStatus::Cancelled | AttachmentStatus::Failed
5478                ) {
5479                    return;
5480                }
5481                a.size_bytes as u64
5482            }
5483            Ok(None) => crate::files::MAX_FILE_SIZE,
5484            Err(e) => {
5485                warn!(%e, "get attachment for chunk");
5486                crate::files::MAX_FILE_SIZE
5487            }
5488        };
5489
5490        let result = self.file_manager.accept_chunk(
5491            &file_id,
5492            chunk_index,
5493            total_chunks,
5494            data,
5495            expected_size,
5496        );
5497        match result {
5498            Ok(None) => {
5499                // Move offered → downloading on first chunk.
5500                let _ = repo::update_attachment_status(
5501                    &self.db,
5502                    room_id,
5503                    &file_id,
5504                    AttachmentStatus::Downloading,
5505                    None,
5506                );
5507                // Best-effort progress event — we know we've processed
5508                // (chunk_index+1)/total_chunks chunks.
5509                let bytes_so_far = self
5510                    .file_manager
5511                    .progress(&file_id)
5512                    .map(|(b, _)| b)
5513                    .unwrap_or(0);
5514                let _ = self.app_event_tx.send(AppEvent::FileProgress {
5515                    file_id: file_id.clone(),
5516                    bytes_received: bytes_so_far,
5517                    total_bytes: expected_size,
5518                });
5519            }
5520            Ok(Some(completed)) => {
5521                let _ = repo::update_attachment_paths(
5522                    &self.db,
5523                    room_id,
5524                    &file_id,
5525                    Some(&completed.cache_path.to_string_lossy()),
5526                    None,
5527                );
5528                let _ = repo::update_attachment_status(
5529                    &self.db,
5530                    room_id,
5531                    &file_id,
5532                    AttachmentStatus::Ready,
5533                    None,
5534                );
5535                let _ = self.app_event_tx.send(AppEvent::FileReady {
5536                    file_id: file_id.clone(),
5537                });
5538            }
5539            Err(e) => {
5540                let msg = e.to_string();
5541                warn!(%msg, "chunk processing failed");
5542                let _ = repo::update_attachment_status(
5543                    &self.db,
5544                    room_id,
5545                    &file_id,
5546                    AttachmentStatus::Failed,
5547                    Some(&msg),
5548                );
5549                let _ = self.app_event_tx.send(AppEvent::FileFailed {
5550                    file_id: file_id.clone(),
5551                    reason: msg,
5552                });
5553            }
5554        }
5555    }
5556
5557    /// Emit MentionReceived if `body` contains either our full
5558    /// fingerprint or our `HD-XXXX-XXXX` 8-hex-char prefix.
5559    ///
5560    /// huddle 0.7.11: pre-0.7.11 the short-form match used only the
5561    /// first 4-hex group (~65 K possibilities), so unrelated peers
5562    /// sharing a prefix triggered false mentions — and a hostile peer
5563    /// could weaponize a 4-hex literal in their message body to spam
5564    /// the victim's terminal bell, bypassing per-room mute. Bumping to
5565    /// the first 8 hex chars makes the search space 16^8 ≈ 4 billion
5566    /// and effectively eliminates collisions while still being short
5567    /// enough to type as a mention ("hey HD-a3b1c2d4 …").
5568    fn maybe_emit_mention(&self, room_id: &str, body: &str) {
5569        let full = self.identity.fingerprint().to_lowercase();
5570        // First 8 hex chars (two dash-separated groups joined), e.g.
5571        // "a3b1c2d4" of "a3b1-c2d4-…".
5572        let short: String = full.chars().filter(|c| c.is_ascii_hexdigit()).take(8).collect();
5573        let lower = body.to_lowercase();
5574        let hit = lower.contains(full.as_str())
5575            || lower
5576                .split(|c: char| !c.is_ascii_hexdigit())
5577                .any(|tok| tok == short);
5578        if hit {
5579            let _ = self.app_event_tx.send(AppEvent::MentionReceived {
5580                room_id: room_id.to_string(),
5581                body: body.to_string(),
5582            });
5583        }
5584    }
5585
5586    fn decrypt_attachment(
5587        &self,
5588        room_id: &str,
5589        sender_fingerprint: &str,
5590        ciphertext: &[u8],
5591        meta: &EncryptedFileMeta,
5592    ) -> Result<Vec<u8>> {
5593        let mut rooms = self.active_rooms.lock().unwrap();
5594        let room = rooms
5595            .get_mut(room_id)
5596            .ok_or_else(|| HuddleError::Other("not in room".into()))?;
5597        let crypto = room
5598            .crypto
5599            .as_mut()
5600            .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
5601        file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
5602    }
5603
5604    /// huddle 0.5: irreversibly delete this account. Verifies the
5605    /// master passphrase, best-effort `MemberLeave`s every joined room
5606    /// (capped at 2 s so a single unresponsive transport can't hang
5607    /// the wipe), shuts down the network, then deletes the database,
5608    /// keychain salt, log, and config files from `config::data_dir()`.
5609    /// Emits `AppEvent::WentDark` on success so the TUI can show a
5610    /// goodbye modal and exit.
5611    ///
5612    /// In `--no-master-passphrase` mode (`self.session_persist_key`
5613    /// is all-zero), the passphrase check is skipped — the typed
5614    /// `DELETE EVERYTHING` confirmation in the TUI is the only gate.
5615    pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
5616        let no_master = self.session_persist_key == [0u8; 32];
5617        if !no_master {
5618            let salt = storage::keychain::load_or_create_salt()?;
5619            let candidate_master =
5620                storage::keychain::derive_master_key(master_passphrase, &salt)?;
5621            let candidate_subkey =
5622                storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
5623            if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
5624                return Err(HuddleError::Other(
5625                    "incorrect master passphrase".into(),
5626                ));
5627            }
5628        }
5629
5630        let room_ids: Vec<String> = self
5631            .active_rooms
5632            .lock()
5633            .unwrap()
5634            .keys()
5635            .cloned()
5636            .collect();
5637        let _ = tokio::time::timeout(Duration::from_secs(2), async {
5638            for room_id in &room_ids {
5639                if let Err(e) = self.leave_room(room_id).await {
5640                    warn!(%room_id, %e, "go_dark: leave_room failed");
5641                }
5642            }
5643        })
5644        .await;
5645
5646        self.network.shutdown().await;
5647        tokio::time::sleep(Duration::from_millis(300)).await;
5648
5649        let data_dir = config::data_dir();
5650        let candidates = [
5651            "huddle.db",
5652            "huddle.db-shm",
5653            "huddle.db-wal",
5654            "keychain.salt",
5655            "huddle.log",
5656            "config.toml",
5657        ];
5658        for name in &candidates {
5659            let path = data_dir.join(name);
5660            wipe_file(&path);
5661        }
5662        if let Ok(read) = std::fs::read_dir(&data_dir) {
5663            for entry in read.flatten() {
5664                if let Some(name) = entry.file_name().to_str() {
5665                    if name.starts_with("huddle.log.") {
5666                        wipe_file(&entry.path());
5667                    }
5668                }
5669            }
5670        }
5671        // huddle 0.5.1: wipe the attachment cache directory. Each file
5672        // inside is best-effort zeroed first, then the directory
5673        // itself is removed.
5674        let files_dir = data_dir.join("files");
5675        if let Ok(read) = std::fs::read_dir(&files_dir) {
5676            for entry in read.flatten() {
5677                let path = entry.path();
5678                if path.is_file() {
5679                    wipe_file(&path);
5680                } else if path.is_dir() {
5681                    // Two-level nesting (room_id subdirs) — sweep their
5682                    // contents too.
5683                    if let Ok(inner) = std::fs::read_dir(&path) {
5684                        for inner_entry in inner.flatten() {
5685                            if inner_entry.path().is_file() {
5686                                wipe_file(&inner_entry.path());
5687                            }
5688                        }
5689                    }
5690                    let _ = std::fs::remove_dir(&path);
5691                }
5692            }
5693        }
5694        let _ = std::fs::remove_dir(&files_dir);
5695        let _ = std::fs::remove_dir(&data_dir);
5696
5697        let _ = self.app_event_tx.send(AppEvent::WentDark);
5698        Ok(())
5699    }
5700}
5701
5702/// huddle 0.5.1: parse `input` as a huddle ID — either `HD-`-prefixed
5703/// or a bare 24-char hex run with or without dashes — and return it in
5704/// the canonical lowercase-dashed form `xxxx-xxxx-...-xxxx` that
5705/// matches `identity::compute_fingerprint`'s output. Returns None for
5706/// anything that isn't a syntactic ID (the caller falls back to
5707/// username lookup).
5708pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
5709    let s = input
5710        .trim()
5711        .trim_start_matches("HD-")
5712        .trim_start_matches("hd-")
5713        .to_string();
5714    let hex_only: String = s.chars().filter(|c| *c != '-').collect();
5715    if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
5716        return None;
5717    }
5718    let lower = hex_only.to_ascii_lowercase();
5719    let chunks: Vec<String> = lower
5720        .as_bytes()
5721        .chunks(4)
5722        .map(|c| std::str::from_utf8(c).unwrap().to_string())
5723        .collect();
5724    Some(chunks.join("-"))
5725}
5726
5727/// huddle 1.2.1: length of a connect code (chars), matching the relay's
5728/// `CONNECT_TOKEN_LEN`.
5729pub const CONNECT_CODE_LEN: usize = 8;
5730/// Crockford base32 alphabet (no I/L/O/U) — matches the relay's generator.
5731const CONNECT_CODE_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
5732
5733/// huddle 1.2.1: canonicalize a typed connect code (uppercase, strip spaces /
5734/// dashes) and validate it's exactly `CONNECT_CODE_LEN` Crockford-base32
5735/// chars. Returns `None` for anything that isn't a well-formed code — so the
5736/// UIs can tell a connect code apart from an HD-ID (24 hex) or a username, and
5737/// route "add by …" input to the right path.
5738pub fn normalize_connect_code(input: &str) -> Option<String> {
5739    let up: String = input
5740        .trim()
5741        .to_ascii_uppercase()
5742        .chars()
5743        .filter(|c| *c != '-' && *c != ' ')
5744        .collect();
5745    if up.len() == CONNECT_CODE_LEN && up.bytes().all(|b| CONNECT_CODE_ALPHABET.contains(&b)) {
5746        Some(up)
5747    } else {
5748        None
5749    }
5750}
5751
5752/// huddle 0.5.2: rank a multiaddr by transport preference. Lower =
5753/// better. Used to sort candidate addresses for the parallel dialer so
5754/// LAN connections get a head-start over relay-hopped ones when wall-
5755/// times are close. The numeric values are arbitrary; only the
5756/// ordering matters.
5757fn address_preference(addr: &str) -> u8 {
5758    if addr.contains("/p2p-circuit") {
5759        return 9; // relay-hopped — bottom of the list
5760    }
5761    if let Some(rest) = addr.strip_prefix("/ip4/") {
5762        if let Some(ip_str) = rest.split('/').next() {
5763            if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
5764                if ip.is_loopback() {
5765                    return 1; // useful for tests
5766                }
5767                if is_rfc1918(&ip) || ip.is_link_local() {
5768                    return 0; // LAN — wins ties
5769                }
5770                return 3; // public ipv4
5771            }
5772        }
5773        return 3;
5774    }
5775    if addr.starts_with("/ip6/") {
5776        return 4;
5777    }
5778    if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
5779        return 5;
5780    }
5781    7
5782}
5783
5784/// True for IPv4 addresses in private (RFC 1918) ranges — 10/8,
5785/// 172.16/12, 192.168/16. Used by `address_preference` to score LAN
5786/// dials ahead of public-IP and relay-hopped ones.
5787fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
5788    let octets = ip.octets();
5789    octets[0] == 10
5790        || (octets[0] == 172 && (16..=31).contains(&octets[1]))
5791        || (octets[0] == 192 && octets[1] == 168)
5792}
5793
5794/// Short label for an HD ID, used only in error messages — strips the
5795/// fingerprint down to its first four hex chars with the brand prefix
5796/// so the message reads naturally.
5797fn short_fp_for_msg(fingerprint: &str) -> String {
5798    let head: String = fingerprint
5799        .chars()
5800        .filter(|c| *c != '-')
5801        .take(4)
5802        .collect::<String>()
5803        .to_ascii_uppercase();
5804    format!("HD-{}…", head)
5805}
5806
5807/// Constant-time 32-byte equality. Used by `go_dark` to compare a
5808/// re-derived HKDF subkey to the in-memory `session_persist_key`
5809/// without leaking timing information about which byte differed.
5810fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
5811    let mut diff = 0u8;
5812    for i in 0..32 {
5813        diff |= a[i] ^ b[i];
5814    }
5815    diff == 0
5816}
5817
5818/// Best-effort file wipe: overwrite with zeros, then delete. Missing /
5819/// permission-denied files are logged and skipped. Called from
5820/// `go_dark` only — not a general-purpose util.
5821fn wipe_file(path: &Path) {
5822    use std::io::Write;
5823    // huddle 0.7.11: write zeros in a 64 KiB scratch buffer instead of
5824    // allocating a vec the full file size. The original implementation
5825    // OOM'd `go_dark` mid-wipe whenever a user had downloaded a
5826    // multi-GB attachment — the panic aborted before DB / config wipe,
5827    // leaving a half-wiped data dir.
5828    const SCRATCH: usize = 64 * 1024;
5829    if let Ok(meta) = std::fs::metadata(path) {
5830        if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
5831            let zeros = [0u8; SCRATCH];
5832            let mut remaining = meta.len();
5833            while remaining > 0 {
5834                let n = remaining.min(SCRATCH as u64) as usize;
5835                if f.write_all(&zeros[..n]).is_err() {
5836                    break;
5837                }
5838                remaining -= n as u64;
5839            }
5840            let _ = f.sync_all();
5841        }
5842    }
5843    if let Err(e) = std::fs::remove_file(path) {
5844        if e.kind() != std::io::ErrorKind::NotFound {
5845            warn!(?path, %e, "wipe_file: remove failed");
5846        }
5847    }
5848}
5849
5850/// Use the platform's default opener on `path`.
5851fn open_with_system(path: &str) -> Result<()> {
5852    #[cfg(target_os = "macos")]
5853    let cmd = "open";
5854    #[cfg(target_os = "linux")]
5855    let cmd = "xdg-open";
5856    #[cfg(target_os = "windows")]
5857    let cmd = "cmd";
5858    #[cfg(target_os = "windows")]
5859    let args = vec!["/C", "start", "", path];
5860    #[cfg(not(target_os = "windows"))]
5861    let args = vec![path];
5862
5863    std::process::Command::new(cmd)
5864        .args(args)
5865        .spawn()
5866        .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
5867    Ok(())
5868}
5869
5870// Module-level salt cache: room_id -> salt. Populated when we receive
5871// announcements; queried by join_room.
5872static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
5873    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
5874
5875/// huddle 1.1.4: keep `ROOM_SALT_CACHE` bounded. A long-lived client that
5876/// observes many room announcements could otherwise grow it without limit.
5877/// Salts are cheaply re-learned from the next announcement, so evicting an
5878/// arbitrary entry once the cap is reached is harmless.
5879const ROOM_SALT_CACHE_CAP: usize = 4096;
5880
5881fn remember_room_salt(room_id: &str, salt: Vec<u8>) {
5882    let mut cache = ROOM_SALT_CACHE.lock().unwrap();
5883    if !cache.contains_key(room_id) && cache.len() >= ROOM_SALT_CACHE_CAP {
5884        if let Some(k) = cache.keys().next().cloned() {
5885            cache.remove(&k);
5886        }
5887    }
5888    cache.insert(room_id.to_string(), salt);
5889}
5890
5891/// Public accessor for the Argon2id salt length used when deriving room
5892/// passphrase keys. Exists so downstream tooling (status pages, debug
5893/// CLIs, integration tests) can confirm the expected size without
5894/// re-importing the constant from `crypto::passphrase`.
5895pub fn salt_len() -> usize {
5896    SALT_LEN
5897}
5898
5899fn now_unix() -> i64 {
5900    SystemTime::now()
5901        .duration_since(UNIX_EPOCH)
5902        .unwrap()
5903        .as_secs() as i64
5904}
5905
5906fn now_unix_ms() -> i64 {
5907    SystemTime::now()
5908        .duration_since(UNIX_EPOCH)
5909        .unwrap()
5910        .as_millis() as i64
5911}
5912
5913/// Phase B: generate a fresh 24-char base64-ish passphrase for the
5914/// rotation that follows a kick. Sourced from `OsRng` directly so the
5915/// kicker doesn't have to think up a strong one on the spot. Returned
5916/// to the owner via the kick-result modal for OOB sharing with the
5917/// remaining members.
5918fn generate_join_passphrase() -> String {
5919    use rand::RngCore;
5920    let mut bytes = [0u8; 16];
5921    rand::thread_rng().fill_bytes(&mut bytes);
5922    // Use URL-safe-no-pad so the user can read aloud / paste without
5923    // worrying about `=` padding or `+` getting URL-escaped.
5924    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
5925}
5926
5927/// Phase F: short human-readable join code. 8 chars from a 31-symbol
5928/// alphabet (no easily-confused chars like 0/O/I/1/L) ≈ 39.6 bits —
5929/// plenty for a 10-minute online gate since the owner's client checks
5930/// exact-match (not brute-force-able offline).
5931///
5932/// huddle 0.7.11: comment said "32-symbol" but the literal contains 31
5933/// bytes (A-Z minus I/L/O = 23, plus 2-9 = 8, total 31). Doc updated
5934/// to match.
5935fn generate_alphanumeric_code(len: usize) -> String {
5936    use rand::Rng;
5937    const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
5938    let mut rng = rand::thread_rng();
5939    let mut out = String::with_capacity(len + 1);
5940    for i in 0..len {
5941        if i == 4 && len == 8 {
5942            out.push('-'); // pretty: XXXX-XXXX
5943        }
5944        let idx = rng.gen_range(0..ALPHABET.len());
5945        out.push(ALPHABET[idx] as char);
5946    }
5947    out
5948}
5949
5950#[cfg(test)]
5951mod parser_tests {
5952    use super::parse_dial_address;
5953
5954    #[test]
5955    fn parses_ipv4_port() {
5956        let m = parse_dial_address("10.3.72.53:9027").unwrap();
5957        assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
5958    }
5959
5960    #[test]
5961    fn parses_bracketed_ipv6() {
5962        let m = parse_dial_address("[::1]:9027").unwrap();
5963        assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
5964    }
5965
5966    #[test]
5967    fn rejects_unbracketed_ipv6() {
5968        let err = parse_dial_address("fe80::1:9027").unwrap_err();
5969        assert!(err.to_string().contains("brackets"));
5970    }
5971
5972    #[test]
5973    fn passes_through_raw_multiaddr() {
5974        let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
5975        assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
5976    }
5977
5978    #[test]
5979    fn empty_address_is_error() {
5980        assert!(parse_dial_address("   ").is_err());
5981    }
5982
5983    #[test]
5984    fn rejects_bad_port() {
5985        assert!(parse_dial_address("1.2.3.4:notaport").is_err());
5986    }
5987}
5988
5989#[cfg(test)]
5990mod transport_preference_tests {
5991    use super::{address_preference, normalize_to_fingerprint};
5992
5993    #[test]
5994    fn lan_beats_public_beats_circuit() {
5995        let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
5996        let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
5997        let circuit = address_preference(
5998            "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
5999        );
6000        assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
6001        assert!(
6002            pub_v4 < circuit,
6003            "public {} should beat circuit {}",
6004            pub_v4,
6005            circuit
6006        );
6007    }
6008
6009    #[test]
6010    fn all_rfc1918_ranges_are_lan() {
6011        assert_eq!(
6012            address_preference("/ip4/10.0.0.1/tcp/9027"),
6013            address_preference("/ip4/192.168.0.1/tcp/9027"),
6014        );
6015        assert_eq!(
6016            address_preference("/ip4/172.16.0.1/tcp/9027"),
6017            address_preference("/ip4/192.168.0.1/tcp/9027"),
6018        );
6019        // 172.32.x.x is OUTSIDE the 172.16-31 RFC1918 slice.
6020        assert!(
6021            address_preference("/ip4/172.32.0.1/tcp/9027")
6022                > address_preference("/ip4/172.16.0.1/tcp/9027")
6023        );
6024    }
6025
6026    #[test]
6027    fn normalize_id_accepts_branded_and_raw() {
6028        let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6029        assert_eq!(
6030            normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
6031            Some(canon)
6032        );
6033        assert_eq!(
6034            normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
6035            Some(canon)
6036        );
6037        assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
6038        assert!(normalize_to_fingerprint("alice").is_none());
6039        assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
6040    }
6041}
6042
6043#[cfg(test)]
6044mod canonical_dm_room_id_tests {
6045    use super::canonical_dm_room_id;
6046
6047    #[test]
6048    fn dm_room_id_is_commutative() {
6049        // The single load-bearing property: both peers, no matter who
6050        // calls `start_direct` first, derive identical IDs.
6051        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6052        let b = "1111-2222-3333-4444-5555-6666";
6053        assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
6054    }
6055
6056    #[test]
6057    fn dm_room_id_differs_per_pair() {
6058        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6059        let b = "1111-2222-3333-4444-5555-6666";
6060        let c = "9999-8888-7777-6666-5555-4444";
6061        assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
6062        assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
6063    }
6064
6065    #[test]
6066    fn dm_room_id_is_stable() {
6067        // Deterministic by construction; this guards against
6068        // accidentally mixing in a timestamp or nonce in a future
6069        // refactor — that would break idempotency across peers.
6070        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6071        let b = "1111-2222-3333-4444-5555-6666";
6072        let id1 = canonical_dm_room_id(a, b);
6073        let id2 = canonical_dm_room_id(a, b);
6074        assert_eq!(id1, id2);
6075        // Same length as `derive_room_id` output (32 hex chars / 16
6076        // bytes) so DM IDs are indistinguishable from group IDs at the
6077        // topic-name layer.
6078        assert_eq!(id1.len(), 32);
6079    }
6080}