//! Opt-in discovery posture (W7 P6) — the pure policy that turns a (network mode, //! "want to be Discoverable?") pair into *which* n0 DNS address-lookup services to //! run, plus the Discoverable time-box. The actual iroh wiring (clearing and //! reinstalling the endpoint's address-lookup services) lives in `core`; this module //! is the small, testable decision surface so the policy can be unit-tested without //! standing up an endpoint. //! //! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16): //! - **Resolving is always allowed on relay-capable modes** — a stationary friend //! (typically in `Normal`) must be able to look up a friend who moved networks. A //! resolve is a DNS query to n0 that publishes nothing, but still exposes query //! timing/source metadata to n0; it only fires when a saved address is stale and //! the dial falls through to discovery. //! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover //! publishes their address to n0 DNS; everyone else just looks it up. //! - **Stopping publishing removes the local publisher service**; iroh does not //! expose an explicit unpublish call here, so already-published pkarr records can //! linger until their default ~30s TTL expires. //! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish //! ever touches n0 there, regardless of the Discoverable toggle. use crate::config::NetworkMode; use crate::presence::PresenceMode; use std::time::Duration; /// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is /// a publish-to-n0 beacon, so it auto-expires rather than standing indefinitely; the /// user can re-toggle to restart the clock. (Open decision #3 settled: 30-minute /// time-box, not sticky-until-off.) pub const DISCOVERY_TIMEBOX: Duration = Duration::from_secs(30 * 60); /// Which n0 DNS address-lookup services to install for a posture. The in-memory /// lookup (fed by tickets + gossip, fully server-free) is always present and is /// deliberately *not* represented here — these flags are only about n0 DNS. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LookupPlan { /// Resolve a moved friend's address by their (stable) node id via n0 DNS. A /// passive query — publishes nothing — and only fires when a saved address is /// stale enough that the dial falls through to discovery. pub resolver: bool, /// Publish *our* current address to n0 DNS so friends can find us after we moved /// networks. The one privacy-costly bit; on only while `Discoverable`. pub publisher: bool, } /// The n0 DNS services wanted for a `(network_mode, want_publish)` pair. Pure. /// /// `want_publish` is `true` exactly when the presence posture is `Discoverable` /// (see [`crate::presence::PresenceMode::publishes_to_discovery`]). pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan { match network_mode { // The explicit serverless posture: no n0 contact at all, even to resolve. // A Discoverable toggle here is intentionally inert. NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false, }, // Relay-capable: always resolve (so a stationary friend can find a mover); // publish only when the user opted into Discoverable. NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan { resolver: true, publisher: want_publish, }, } } /// Decide which presence mode may be committed after attempting to apply discovery /// services for `requested`. /// /// On failure, keep the previous mode: it is the only locally truthful state because /// the endpoint's discovery services may still reflect the old posture. Same-mode /// requests are no-ops from a presence-truth perspective and do not surface an error. pub fn resolve_presence_transition( previous: PresenceMode, requested: PresenceMode, apply_ok: bool, ) -> (PresenceMode, Option) { if previous == requested { return (previous, None); } if apply_ok { (requested, None) } else { ( previous, Some(format!( "Couldn't update discovery mode; keeping {previous}." )), ) } } #[cfg(test)] mod tests { use super::*; #[test] fn relay_modes_always_resolve_and_publish_only_when_wanted() { for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] { assert_eq!( lookup_plan(mode, false), LookupPlan { resolver: true, publisher: false }, "{mode:?}: resolve always on, no publish when not Discoverable" ); assert_eq!( lookup_plan(mode, true), LookupPlan { resolver: true, publisher: true }, "{mode:?}: Discoverable adds publish on top of resolve" ); } } #[test] fn direct_only_never_touches_n0_even_when_discoverable() { assert_eq!( lookup_plan(NetworkMode::DirectOnly, false), LookupPlan { resolver: false, publisher: false } ); // The serverless posture overrides the Discoverable request entirely. assert_eq!( lookup_plan(NetworkMode::DirectOnly, true), LookupPlan { resolver: false, publisher: false } ); } #[test] fn timebox_is_thirty_minutes() { assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800)); } #[test] fn presence_transition_commits_requested_mode_after_successful_apply() { assert_eq!( resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true), (PresenceMode::Discoverable, None) ); } #[test] fn presence_transition_keeps_previous_mode_when_apply_fails() { let (mode, err) = resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false); assert_eq!(mode, PresenceMode::Normal); assert!(err.unwrap().contains("keeping Normal")); } #[test] fn presence_transition_keeps_discoverable_when_off_transition_fails() { let (mode, err) = resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false); assert_eq!(mode, PresenceMode::Discoverable); assert!(err.unwrap().contains("keeping Discoverable")); } #[test] fn presence_transition_same_mode_is_noop_without_error() { assert_eq!( resolve_presence_transition( PresenceMode::Discoverable, PresenceMode::Discoverable, false ), (PresenceMode::Discoverable, None) ); } }