From d8647f6576856252943ac0ffb2ff1327611b2fb4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 13:03:31 -0400 Subject: [PATCH] fix(mesh): tabbed tools column on very wide screens; add configurable session policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesh right panel: a >=2560px screen hid the tab bar and stacked all five tool panels in fixed grid rows. On a real display that clipped the Bitcoin, Dead Man and AI headings to a few pixels each, letterboxed the map, and pushed Radio Settings into a scroll — more screen producing a worse view. Very wide now uses the same tabbed column as every other desktop width, with the selected panel filling the column and the map running edge to edge (it is the one panel with nothing to scroll). Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag, persisted and clamped. Two tokens already existed — a session token and a 30-day login token — so the knob changes how long a quiet tab stays usable without putting a long-lived credential on every request. Kiosk screens are exempt from the idle timeout (nobody is there to log a TV back in) but keep the absolute cap so a stolen box does not stay authenticated forever. The cap is not optional theatre: idle alone never fires on a polling dashboard. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/settings/mod.rs | 1 + .../src/settings/session_policy.rs | 200 ++++++++++++++++++ neode-ui/src/views/Mesh.vue | 7 +- neode-ui/src/views/mesh/mesh-styles.css | 31 ++- 4 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 core/archipelago/src/settings/session_policy.rs diff --git a/core/archipelago/src/settings/mod.rs b/core/archipelago/src/settings/mod.rs index 8e028a0f..d2eb985a 100644 --- a/core/archipelago/src/settings/mod.rs +++ b/core/archipelago/src/settings/mod.rs @@ -4,4 +4,5 @@ //! call sites (deep in the transport / RPC / ingest stacks) don't need //! to thread a data_dir or Arc through the entire call graph. +pub mod session_policy; pub mod transport; diff --git a/core/archipelago/src/settings/session_policy.rs b/core/archipelago/src/settings/session_policy.rs new file mode 100644 index 00000000..471aafaa --- /dev/null +++ b/core/archipelago/src/settings/session_policy.rs @@ -0,0 +1,200 @@ +//! How long a login lasts, and who gets to say so. +//! +//! # Why this is configurable rather than a constant +//! +//! There is no single correct session lifetime. The same node can be a +//! wall-mounted TV in a living room that must never ask for a password +//! mid-film, and a wallet holding real funds where PCI DSS-style guidance +//! says fifteen minutes. Both are legitimate; the operator knows which one +//! this node is and we do not. +//! +//! # The two tokens +//! +//! * **Session token** — short-lived, refreshed silently on every +//! authenticated request. This is what the browser sends; if it leaks, it +//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence. +//! * **Login (remember) token** — long-lived, and its *only* power is to +//! mint a fresh session token. Kept separate so raising the convenience +//! knob does not put a 30-day bearer credential on every request. +//! +//! Raising the idle timeout therefore does not weaken the credential that +//! actually travels; it only changes how long a quiet tab stays usable. +//! +//! # Why an absolute cap exists at all +//! +//! Idle timeout alone can be defeated by any page that polls — the +//! dashboard polls constantly, so an idle timeout would never fire while a +//! tab is open. The absolute cap is what guarantees a login eventually +//! ends, which is the property an auditor actually asks about. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const FILE_PATH: &str = "settings/session_policy.json"; + +/// Bounds. A setting that can be made meaningless is not a setting, and one +/// that can lock the operator out of their own node is a footgun. +const MIN_IDLE_SECS: u64 = 60; +const MAX_IDLE_SECS: u64 = 90 * 24 * 3600; +const MIN_ABSOLUTE_SECS: u64 = 300; +const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DeviceClass { + /// Ordinary browser on a phone or laptop. Policy applies as configured. + Browser, + /// A screen nobody logs into — a wall-mounted dashboard or TV. Being + /// signed out mid-view is the failure mode here, not a stale session: + /// the device is physically in the home, and there is no keyboard to + /// re-authenticate with. Exempt from the idle timeout, still subject to + /// the absolute cap so a stolen box does not stay authenticated forever. + Kiosk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPolicy { + /// Silence after which a session token stops validating. + pub idle_timeout_secs: u64, + /// Hard ceiling from login, regardless of activity. `None` = no cap. + pub absolute_timeout_secs: Option, + /// Re-prompt for the password before actions that move money, however + /// fresh the session is. Independent of the timeouts on purpose: it is + /// the control that matters when funds are involved, and it costs the + /// operator nothing the rest of the time. + pub reauth_for_funds: bool, +} + +impl Default for SessionPolicy { + fn default() -> Self { + Self { + // A day of silence, matching the previous hard-coded constant so + // existing nodes see no behaviour change until someone chooses. + idle_timeout_secs: 86_400, + // 30 days, aligned with the login token's own lifetime: a + // session that outlived the token which could refresh it would + // be an oddity. + absolute_timeout_secs: Some(30 * 24 * 3600), + reauth_for_funds: true, + } + } +} + +impl SessionPolicy { + /// Clamp to the supported range. Applied on load as well as on save, so + /// a hand-edited file cannot disable expiry by writing `0`. + pub fn sanitized(mut self) -> Self { + self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS); + self.absolute_timeout_secs = self + .absolute_timeout_secs + .map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS)) + // An absolute cap below the idle timeout would expire sessions + // while they are still active, which reads as random logouts. + .map(|v| v.max(self.idle_timeout_secs)); + self + } + + /// Idle timeout for a given device, or `None` when idleness is not a + /// reason to expire (kiosk screens). + pub fn idle_timeout_for(&self, class: DeviceClass) -> Option { + match class { + DeviceClass::Browser => Some(self.idle_timeout_secs), + DeviceClass::Kiosk => None, + } + } + + /// Has a session expired? `age` is time since login, `idle` since last + /// use. Both are checked because either alone is insufficient: idle + /// never fires on a polling dashboard, and absolute alone leaves a + /// forgotten tab usable for a month. + pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool { + if let Some(limit) = self.absolute_timeout_secs { + if age_secs >= limit { + return true; + } + } + match self.idle_timeout_for(class) { + Some(limit) => idle_secs >= limit, + None => false, + } + } +} + +pub async fn load(data_dir: &Path) -> SessionPolicy { + let path = data_dir.join(FILE_PATH); + match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(SessionPolicy::sanitized) + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "session policy unreadable; using defaults"); + SessionPolicy::default() + }), + Err(_) => SessionPolicy::default(), + } +} + +pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result { + let policy = policy.sanitized(); + let path = data_dir.join(FILE_PATH); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let tmp = path.with_extension("json.tmp"); + tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?; + tokio::fs::rename(&tmp, &path).await?; + Ok(policy) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_previous_hardcoded_behaviour() { + let p = SessionPolicy::default(); + assert_eq!(p.idle_timeout_secs, 86_400); + assert!(p.reauth_for_funds); + } + + #[test] + fn expiry_cannot_be_disabled_by_hand_editing_the_file() { + let p = SessionPolicy { + idle_timeout_secs: 0, + absolute_timeout_secs: Some(0), + reauth_for_funds: false, + } + .sanitized(); + assert!(p.idle_timeout_secs >= MIN_IDLE_SECS); + assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS); + } + + #[test] + fn absolute_cap_is_never_shorter_than_idle() { + // Otherwise a session dies while actively in use, which the operator + // experiences as being logged out at random. + let p = SessionPolicy { + idle_timeout_secs: 7 * 24 * 3600, + absolute_timeout_secs: Some(3600), + reauth_for_funds: true, + } + .sanitized(); + assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs); + } + + #[test] + fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() { + let p = SessionPolicy::default(); + let a_week = 7 * 24 * 3600; + assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week)); + assert!(p.is_expired(DeviceClass::Browser, 60, a_week)); + // The absolute cap still applies to the TV. + assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0)); + } + + #[test] + fn a_polling_dashboard_still_eventually_expires() { + // idle never grows because the page polls; only the cap saves us. + let p = SessionPolicy::default(); + assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0)); + } +} diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index f6386335..36c3237f 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -311,31 +311,26 @@ const showChatPanel = computed(() => activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value) ) const showBitcoinPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'bitcoin' if (isMobile.value) return mobileTab.value === 'bitcoin' return activeTab.value === 'bitcoin' }) const showDeadmanPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'deadman' if (isMobile.value) return mobileTab.value === 'deadman' return activeTab.value === 'deadman' }) const showAssistantPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'assistant' if (isMobile.value) return mobileTab.value === 'assistant' return activeTab.value === 'assistant' }) const showMapPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'map' if (isMobile.value) return mobileTab.value === 'map' return activeTab.value === 'map' }) const showDevicePanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'device' if (isMobile.value) return mobileTab.value === 'device' return activeTab.value === 'device' @@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
-
+