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