diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index 53822927638..ee2751e29a7 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -5,12 +5,15 @@ use futures_util::{SinkExt, StreamExt}; use nostr::{Event, Keys, Tag}; use serde_json::{json, Value}; use tokio::time::timeout; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderMap; +use tokio_tungstenite::tungstenite::{ + client::IntoClientRequest, + handshake::client::Request, + http::{HeaderMap, HeaderValue}, +}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tracing::debug; -use crate::error::WsClientError; +use crate::error::{NipFiConnectError, WsClientError}; use crate::message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage}; type WsStream = WebSocketStream>; @@ -30,9 +33,75 @@ pub struct NostrWsConnection { buffer: VecDeque, pending_challenge: Option, relay_url: String, + // Only active during the opt-in NIP-FI handshake. Ordinary connections + // retain their existing behavior; authenticated traffic is not restricted. + auth_buffer_remaining: Option, + protected_assertion: Option, } impl NostrWsConnection { + /// Sends one explicit NIP-FI bearer header on a WSS upgrade, then proves + /// possession of `keys` using NIP-42. The relay verifies the assertion and + /// its binding to this key; this client does not verify or mint JWTs. + /// + /// `url` must come from trusted application policy and contain no user + /// information, query, or fragment. `assertion` is the complete + /// `Bearer ` field, obtained from the issuer-bound login. + /// The compact JWS is limited to 16 KiB; claims remain opaque. + /// No NIP-OA delegation or unauthenticated fallback is attempted. + /// `budget` bounds the complete upgrade and authentication, including DNS + /// and TLS, and must be nonzero. Dropping this future closes its socket. + /// + /// The caller owns logout/account-switch cancellation and connection + /// lifetime. Refresh requires a new connection with a fresh assertion; + /// there is no in-band renewal. This method publishes no user event. + /// + /// This crate suppresses AUTH payload logs and redacts connection errors. + /// Hosts must disable tungstenite raw-wire TRACE for this connection: + /// dependency tracing can dump serialized upgrade bytes, including the + /// assertion, despite the header's sensitive marker. + pub async fn connect_authenticated_with_nip_fi( + url: &str, + keys: &Keys, + assertion: &HeaderValue, + budget: Duration, + ) -> Result { + let request = nip_fi_request(url, assertion)?; + Self::connect_nip_fi_request(request, url, keys, assertion, budget).await + } + + async fn connect_nip_fi_request( + request: Request, + url: &str, + keys: &Keys, + assertion: &HeaderValue, + budget: Duration, + ) -> Result { + if budget.is_zero() { + return Err(NipFiConnectError::InvalidConfiguration); + } + let token = nip_fi_token(assertion)?; + timeout(budget, async { + let (ws, _) = connect_async(request) + .await + .map_err(WsClientError::WebSocket)?; + let mut conn = Self { + ws, + buffer: VecDeque::new(), + pending_challenge: None, + relay_url: url.to_owned(), + auth_buffer_remaining: Some(64 * 1024), + protected_assertion: Some(token.to_owned()), + }; + conn.authenticate_inner(keys, None, Some(token)).await?; + conn.auth_buffer_remaining = None; + Ok::<_, WsClientError>(conn) + }) + .await + .map_err(|_| NipFiConnectError::Timeout)? + .map_err(NipFiConnectError::from) + } + /// Connects to the relay at `url` and performs NIP-42 authentication with `keys`. /// /// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event. @@ -90,6 +159,8 @@ impl NostrWsConnection { buffer: VecDeque::new(), pending_challenge: None, relay_url: url.to_string(), + auth_buffer_remaining: None, + protected_assertion: None, }) } @@ -100,15 +171,39 @@ impl NostrWsConnection { &mut self, keys: &Keys, auth_tag: Option<&Tag>, + ) -> Result<(), WsClientError> { + self.authenticate_inner(keys, auth_tag, None).await + } + + async fn authenticate_inner( + &mut self, + keys: &Keys, + auth_tag: Option<&Tag>, + protected_assertion: Option<&str>, ) -> Result<(), WsClientError> { let challenge = self .wait_for_auth_challenge(Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS)) .await?; + // An untrusted relay can echo our upgrade credential as its challenge. + // Never turn that credential into a signed event or a trace record. + if protected_assertion.is_some_and(|token| challenge.contains(token)) { + return Err(WsClientError::AuthFailed( + "invalid authentication challenge".into(), + )); + } + let auth_event = build_auth_event(&challenge, &self.relay_url, keys, auth_tag)?; let event_id = auth_event.id.to_hex(); - self.send_raw(&json!(["AUTH", auth_event])).await?; + let auth = json!(["AUTH", auth_event]); + if protected_assertion.is_some() { + // NIP-FI authentication traces must not contain relay-controlled + // challenge content. Keep the ordinary connector's behavior intact. + self.ws.send(Message::Text(auth.to_string().into())).await?; + } else { + self.send_raw(&auth).await?; + } let ok = self .wait_for_ok(&event_id, Duration::from_secs(AUTH_OK_TIMEOUT_SECS)) @@ -149,7 +244,9 @@ impl NostrWsConnection { /// Sends a raw JSON value as a WebSocket text frame. pub async fn send_raw(&mut self, value: &Value) -> Result<(), WsClientError> { let text = serde_json::to_string(value)?; - debug!("→ relay: {text}"); + if self.protected_assertion.is_none() { + debug!("→ relay: {text}"); + } self.ws.send(Message::Text(text.into())).await?; Ok(()) } @@ -168,7 +265,7 @@ impl NostrWsConnection { match raw { Message::Text(text) => { - let msg = parse_relay_message(&text)?; + let msg = self.parse_message(&text)?; if let RelayMessage::Auth { ref challenge } = msg { self.pending_challenge = Some(challenge.clone()); } @@ -221,7 +318,8 @@ impl NostrWsConnection { match raw { Message::Text(text) => { - let msg = parse_relay_message(&text)?; + self.check_auth_buffer(&text)?; + let msg = self.parse_message(&text)?; match msg { RelayMessage::Auth { challenge } => { if challenge.len() > 1024 { @@ -278,7 +376,8 @@ impl NostrWsConnection { match raw { Message::Text(text) => { - let msg = parse_relay_message(&text)?; + self.check_auth_buffer(&text)?; + let msg = self.parse_message(&text)?; match msg { RelayMessage::Ok(ok) if ok.event_id == event_id => return Ok(ok), RelayMessage::Auth { ref challenge } => { @@ -296,8 +395,95 @@ impl NostrWsConnection { } } } + + fn parse_message(&self, text: &str) -> Result { + if let Some(token) = &self.protected_assertion { + let decoded: Value = serde_json::from_str(text).map_err(|_| { + WsClientError::AuthFailed("invalid protected relay response".to_owned()) + })?; + if decoded.to_string().contains(token) { + return Err(WsClientError::AuthFailed( + "invalid protected relay response".to_owned(), + )); + } + } + parse_relay_message(text).map_err(|error| { + if self.protected_assertion.is_some() { + WsClientError::AuthFailed("invalid protected relay response".to_owned()) + } else { + error + } + }) + } + + fn check_auth_buffer(&mut self, text: &str) -> Result<(), WsClientError> { + if let Some(remaining) = &mut self.auth_buffer_remaining { + if self.buffer.len() >= 64 { + return Err(WsClientError::UnexpectedMessage( + "authentication buffer limit".into(), + )); + } + *remaining = remaining.checked_sub(text.len()).ok_or_else(|| { + WsClientError::UnexpectedMessage("authentication buffer limit".into()) + })?; + } + Ok(()) + } +} + +fn nip_fi_token(assertion: &HeaderValue) -> Result<&str, NipFiConnectError> { + // Match the bounded compact-JWS transport shape without interpreting claims. + let token = assertion + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|token| token.len() <= 16 * 1024) + .ok_or(NipFiConnectError::InvalidConfiguration)?; + let mut segments = token.split('.'); + for _ in 0..3 { + if !segments.next().is_some_and(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) { + return Err(NipFiConnectError::InvalidConfiguration); + } + } + if segments.next().is_some() { + return Err(NipFiConnectError::InvalidConfiguration); + } + Ok(token) } +fn nip_fi_request(url: &str, assertion: &HeaderValue) -> Result { + nip_fi_token(assertion)?; + let parsed = url::Url::parse(url).map_err(|_| NipFiConnectError::InvalidConfiguration)?; + if parsed.scheme() != "wss" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(NipFiConnectError::InvalidConfiguration); + } + let mut request = parsed + .as_str() + .into_client_request() + .map_err(|_| NipFiConnectError::InvalidConfiguration)?; + let mut header = assertion.clone(); + header.set_sensitive(true); + request + .headers_mut() + .insert("Nostr-Federated-Identity", header); + Ok(request) +} + +#[cfg(test)] +#[path = "nip_fi_tests.rs"] +mod nip_fi_tests; + /// One-shot helper: connect, authenticate, send one event, disconnect. /// /// Establishes a fresh WebSocket connection, completes NIP-42 authentication, diff --git a/crates/buzz-ws-client/src/error.rs b/crates/buzz-ws-client/src/error.rs index c8781bb591d..85534d96d58 100644 --- a/crates/buzz-ws-client/src/error.rs +++ b/crates/buzz-ws-client/src/error.rs @@ -1,5 +1,50 @@ use thiserror::Error; +/// Redacted outcomes of an assertion-bearing WebSocket handshake. +/// +/// Neither HTTP response bodies nor NIP-42 refusal text are retained: an +/// untrusted relay can echo the assertion in either. These errors never cause +/// an automatic retry without the assertion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum NipFiConnectError { + /// The destination, bearer header, or handshake budget is invalid. + #[error("invalid federated identity connection configuration")] + InvalidConfiguration, + /// HTTP 401 or a rejected NIP-42 proof; refresh or authenticate again. + #[error("relay authentication rejected")] + AuthenticationRejected, + /// HTTP 403; authentication alone does not grant access. + #[error("relay permission denied")] + PermissionDenied, + /// The caller's connection budget or a NIP-42 deadline elapsed. + #[error("relay authentication timed out")] + Timeout, + /// DNS, socket, TLS, or connection closure prevented authentication. + #[error("relay network failure")] + Network, + /// Other HTTP statuses, redirects, malformed messages, or protocol errors. + #[error("relay authentication unavailable")] + Unavailable, +} + +impl From for NipFiConnectError { + fn from(error: WsClientError) -> Self { + use tokio_tungstenite::tungstenite::Error as WsError; + match error { + WsClientError::WebSocket(WsError::Http(response)) => match response.status().as_u16() { + 401 => Self::AuthenticationRejected, + 403 => Self::PermissionDenied, + _ => Self::Unavailable, + }, + WsClientError::WebSocket(WsError::Io(_) | WsError::Tls(_)) + | WsClientError::ConnectionClosed => Self::Network, + WsClientError::Timeout | WsClientError::NoAuthChallenge => Self::Timeout, + WsClientError::AuthFailed(_) => Self::AuthenticationRejected, + _ => Self::Unavailable, + } + } +} + /// Errors returned by [`crate::NostrWsConnection`] and related operations. #[derive(Debug, Error)] pub enum WsClientError { diff --git a/crates/buzz-ws-client/src/nip_fi_tests.rs b/crates/buzz-ws-client/src/nip_fi_tests.rs new file mode 100644 index 00000000000..ac77d263c9d --- /dev/null +++ b/crates/buzz-ws-client/src/nip_fi_tests.rs @@ -0,0 +1,458 @@ +use super::*; +use nostr::{JsonUtil, Kind}; +use std::sync::{Arc, Mutex, OnceLock}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::oneshot, +}; +use tokio_tungstenite::accept_hdr_async; + +const TOKEN: &str = "eyJhbGciOiJFUzI1NiJ9.e30.AAECAw"; +const BUDGET: Duration = Duration::from_secs(2); + +#[derive(Clone, Default)] +struct CrateTrace(Arc>); + +fn crate_trace() -> &'static CrateTrace { + static TRACE: OnceLock = OnceLock::new(); + TRACE.get_or_init(|| { + let trace = CrateTrace::default(); + tracing::subscriber::set_global_default(trace.clone()).unwrap(); + trace + }) +} + +impl tracing::Subscriber for CrateTrace { + fn register_callsite( + &self, + _: &'static tracing::Metadata<'static>, + ) -> tracing::subscriber::Interest { + // Other tests can reach the same callsites before this subscriber. + // Evaluate the active dispatch instead of caching a global decision. + tracing::subscriber::Interest::sometimes() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } + + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + // This asserts this crate's logging contract, not raw wire tracing in + // dependencies. Hosts must not enable credential-bearing wire traces. + metadata.target().starts_with("buzz_ws_client") + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + struct Fields<'a>(&'a mut String); + impl tracing::field::Visit for Fields<'_> { + fn record_debug(&mut self, _: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.push_str(&format!("{value:?}")); + } + } + event.record(&mut Fields(&mut self.0.lock().unwrap())); + } +} + +fn keys() -> Keys { + // Fixed public test identity, never a generated or real employee key. + Keys::parse(&format!("{:064x}", 1)).unwrap() +} + +fn assertion() -> HeaderValue { + HeaderValue::from_str(&format!("Bearer {TOKEN}")).unwrap() +} + +async fn listener() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("wss://{}/tenant", listener.local_addr().unwrap()); + (listener, relay) +} + +async fn connect(relay: &str, budget: Duration) -> Result { + let assertion = assertion(); + let mut request = nip_fi_request(relay, &assertion).unwrap(); + // Private test seam: identical production request/authentication path, + // with plaintext transport only for this loopback fixture. + *request.uri_mut() = relay.replacen("wss:", "ws:", 1).parse().unwrap(); + NostrWsConnection::connect_nip_fi_request(request, relay, &keys(), &assertion, budget).await +} + +#[tokio::test] +#[allow(clippy::result_large_err)] // tungstenite fixes the handshake callback's error type. +async fn upgrade_has_one_sensitive_header_and_same_key_nip42_proof() { + let trace = crate_trace(); + let (listener, relay) = listener().await; + let expected_relay = relay.clone(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = accept_hdr_async(socket, |request: &Request, response| { + assert_eq!( + request + .headers() + .get_all("nostr-federated-identity") + .iter() + .count(), + 1 + ); + assert_eq!( + request.headers()["nostr-federated-identity"], + format!("Bearer {TOKEN}") + ); + assert!(request.headers().get("authorization").is_none()); + assert!(request.headers().get("x-bb-session-credential").is_none()); + assert_eq!(request.uri().path(), "/tenant"); + assert!(request.uri().query().is_none()); + Ok(response) + }) + .await + .unwrap(); + ws.send(Message::text(r#"["AUTH","fixture-challenge"]"#)) + .await + .unwrap(); + let text = ws.next().await.unwrap().unwrap().into_text().unwrap(); + assert!(!text.contains(TOKEN)); + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "AUTH"); + let proof = Event::from_json(frame[1].to_string()).unwrap(); + proof.verify().unwrap(); + assert_eq!(proof.pubkey, keys().public_key()); + assert_eq!(proof.kind, Kind::Authentication); + let tag = |name: &str| { + proof + .tags + .iter() + .find(|t| t.as_slice()[0] == name) + .unwrap() + .as_slice()[1] + .clone() + }; + assert_eq!(tag("relay"), expected_relay); + assert_eq!(tag("challenge"), "fixture-challenge"); + assert_eq!(proof.tags.len(), 2); + ws.send(Message::text(json!(["OK", proof.id, true, ""]).to_string())) + .await + .unwrap(); + }); + let request = nip_fi_request(&relay, &assertion()).unwrap(); + assert!(request.headers()["nostr-federated-identity"].is_sensitive()); + assert!(!format!("{request:?}").contains(TOKEN)); + let connection = connect(&relay, BUDGET).await.unwrap(); + let logged = trace.0.lock().unwrap().clone(); + assert!(logged.contains("NIP-42 authentication successful")); + assert!(!logged.contains(TOKEN)); + assert!(!logged.contains("fixture-challenge")); + assert!(connection.auth_buffer_remaining.is_none()); + server.await.unwrap(); +} + +#[tokio::test] +async fn invalid_public_inputs_and_zero_budget_never_open_a_socket() { + let (listener, relay) = listener().await; + for url in [ + relay.replacen("wss:", "ws:", 1), + format!("{relay}?secret=credential"), + format!("{relay}#fragment"), + relay.replacen("wss://", "wss://user:password@", 1), + ] { + let result = NostrWsConnection::connect_authenticated_with_nip_fi( + &url, + &keys(), + &assertion(), + BUDGET, + ) + .await; + assert!(matches!( + result, + Err(NipFiConnectError::InvalidConfiguration) + )); + } + for value in [ + "", + "Bearer ", + "Basic abc", + "Bearer a.b", + "Bearer a.b.c.d", + "Bearer a..c", + "Bearer a.b.c,other", + "Bearer a.b.c ", + "Bearer a.b.c=", + ] { + let result = NostrWsConnection::connect_authenticated_with_nip_fi( + &relay, + &keys(), + &HeaderValue::from_str(value).unwrap(), + BUDGET, + ) + .await; + assert!(matches!( + result, + Err(NipFiConnectError::InvalidConfiguration) + )); + } + let oversized = + HeaderValue::from_str(&format!("Bearer {}.b.c", "a".repeat(16 * 1024))).unwrap(); + assert!(matches!( + NostrWsConnection::connect_authenticated_with_nip_fi(&relay, &keys(), &oversized, BUDGET) + .await, + Err(NipFiConnectError::InvalidConfiguration) + )); + assert!(matches!( + NostrWsConnection::connect_authenticated_with_nip_fi( + &relay, + &keys(), + &assertion(), + Duration::ZERO + ) + .await, + Err(NipFiConnectError::InvalidConfiguration) + )); + assert!(timeout(Duration::from_millis(30), listener.accept()) + .await + .is_err()); +} + +#[tokio::test] +async fn upgrade_rejections_are_redacted_and_never_redirect_or_fall_back() { + for (status, expected) in [ + (401, NipFiConnectError::AuthenticationRejected), + (403, NipFiConnectError::PermissionDenied), + (302, NipFiConnectError::Unavailable), + (503, NipFiConnectError::Unavailable), + ] { + let (listener, relay) = listener().await; + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let location = format!("ws://{}/forbidden", destination.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = [0; 4096]; + assert!(socket.read(&mut bytes).await.unwrap() > 0); + socket.write_all(format!("HTTP/1.1 {status} Refused\r\nLocation: {location}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{TOKEN}", TOKEN.len()).as_bytes()).await.unwrap(); + // A fallback/retry would create a second socket on this listener. + assert!(timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err()); + }); + let error = connect(&relay, BUDGET).await.err().unwrap(); + assert_eq!(error, expected); + assert!(!format!("{error:?} {error}").contains(TOKEN)); + assert!(timeout(Duration::from_millis(30), destination.accept()) + .await + .is_err()); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn reflected_challenge_is_rejected_without_signing_an_auth_event() { + let (listener, relay) = listener().await; + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + ws.send(Message::text( + json!(["AUTH", format!("echo-{TOKEN}")]).to_string(), + )) + .await + .unwrap(); + let received = timeout(BUDGET, ws.next()).await.unwrap(); + assert!(!matches!(received, Some(Ok(Message::Text(_))))); + }); + let error = connect(&relay, BUDGET).await.err().unwrap(); + assert_eq!(error, NipFiConnectError::AuthenticationRejected); + assert!(!format!("{error:?} {error}").contains(TOKEN)); + server.await.unwrap(); +} + +#[tokio::test] +async fn reflected_auth_refusal_is_redacted() { + let (listener, relay) = listener().await; + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + ws.send(Message::text(r#"["AUTH","fixture"]"#)) + .await + .unwrap(); + let frame: Value = + serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap()).unwrap(); + ws.send(Message::text( + json!(["OK", frame[1]["id"], false, TOKEN]).to_string(), + )) + .await + .unwrap(); + }); + let error = connect(&relay, BUDGET).await.err().unwrap(); + assert_eq!(error, NipFiConnectError::AuthenticationRejected); + assert!(!format!("{error:?} {error}").contains(TOKEN)); + server.await.unwrap(); +} + +#[tokio::test] +async fn complete_budget_bounds_stalled_upgrade_and_closes_socket() { + let (listener, relay) = listener().await; + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + timeout(BUDGET, socket.read_to_end(&mut request)) + .await + .unwrap() + .unwrap(); + assert!(!request.is_empty()); + }); + assert!(matches!( + connect(&relay, Duration::from_millis(50)).await, + Err(NipFiConnectError::Timeout) + )); + server.await.unwrap(); +} + +#[tokio::test] +async fn complete_budget_bounds_stalled_authentication_and_closes_socket() { + let (listener, relay) = listener().await; + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + // Never issue an AUTH challenge. The caller's budget, rather than the + // longer inner challenge deadline, must stop this connection. + assert!(!matches!( + timeout(BUDGET, ws.next()).await.unwrap(), + Some(Ok(Message::Text(_))) + )); + }); + assert!(matches!( + connect(&relay, Duration::from_millis(50)).await, + Err(NipFiConnectError::Timeout) + )); + server.await.unwrap(); +} + +#[tokio::test] +async fn host_cancellation_drops_socket_during_upgrade_and_authentication() { + for upgrade in [false, true] { + let (listener, relay) = listener().await; + let (arrived, ready) = oneshot::channel(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + if upgrade { + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + arrived.send(()).unwrap(); + assert!(!matches!( + timeout(BUDGET, ws.next()).await.unwrap(), + Some(Ok(Message::Text(_))) + )); + } else { + let mut buffer = [0; 4096]; + assert!(socket.read(&mut buffer).await.unwrap() > 0); + arrived.send(()).unwrap(); + let mut remaining = Vec::new(); + timeout(BUDGET, socket.read_to_end(&mut remaining)) + .await + .unwrap() + .unwrap(); + } + }); + let client = tokio::spawn(async move { connect(&relay, BUDGET).await }); + ready.await.unwrap(); + client.abort(); + assert!(matches!(client.await, Err(error) if error.is_cancelled())); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn pre_auth_message_flood_is_bounded_by_count_and_bytes() { + for oversized in [false, true] { + let (listener, relay) = listener().await; + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + let notice = if oversized { + "x".repeat(64 * 1024) + } else { + "x".into() + }; + for _ in 0..65 { + if ws + .send(Message::text(json!(["NOTICE", notice]).to_string())) + .await + .is_err() + { + break; + } + } + }); + assert!(matches!( + connect(&relay, BUDGET).await, + Err(NipFiConnectError::Unavailable) + )); + server.await.unwrap(); + } +} + +#[tokio::test] +#[allow(clippy::result_large_err)] // tungstenite fixes the handshake callback's error type. +async fn ordinary_connector_keeps_its_header_free_nip42_path() { + let (listener, relay) = listener().await; + let url = relay.replacen("wss:", "ws:", 1); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = accept_hdr_async(socket, |request: &Request, response| { + assert!(request.headers().get("nostr-federated-identity").is_none()); + Ok(response) + }) + .await + .unwrap(); + ws.send(Message::text(r#"["AUTH","legacy-fixture"]"#)) + .await + .unwrap(); + let frame: Value = + serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap()).unwrap(); + let proof = Event::from_json(frame[1].to_string()).unwrap(); + proof.verify().unwrap(); + ws.send(Message::text(json!(["OK", proof.id, true, ""]).to_string())) + .await + .unwrap(); + }); + NostrWsConnection::connect_authenticated(&url, &keys(), None) + .await + .unwrap(); + server.await.unwrap(); +} + +#[tokio::test] +async fn protected_connection_rejects_post_auth_escaped_reflection_before_parsing_or_logging() { + let trace = crate_trace(); + let (listener, relay) = listener().await; + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + ws.send(Message::text(r#"["AUTH","public-challenge"]"#)) + .await + .unwrap(); + let frame: Value = + serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap()).unwrap(); + let proof = Event::from_json(frame[1].to_string()).unwrap(); + ws.send(Message::text(json!(["OK", proof.id, true, ""]).to_string())) + .await + .unwrap(); + ws.send(Message::text(format!( + r#"["{}"]"#, + TOKEN.replace('.', "\\u002e") + ))) + .await + .unwrap(); + while ws.next().await.is_some() {} + }); + let mut connection = connect(&relay, BUDGET).await.unwrap(); + let error = connection.next_event(BUDGET).await.unwrap_err(); + assert!(!format!("{error:?}").contains(TOKEN)); + assert!(!trace.0.lock().unwrap().contains(TOKEN)); + connection.disconnect().await.unwrap(); + server.await.unwrap(); +} diff --git a/desktop/EMPLOYEE_SIGN_IN.md b/desktop/EMPLOYEE_SIGN_IN.md new file mode 100644 index 00000000000..ecf4ef07d4a --- /dev/null +++ b/desktop/EMPLOYEE_SIGN_IN.md @@ -0,0 +1,17 @@ +# Square employee desktop sign-in (staging candidate) + +This integration reuses Buzz's native BB session and local signing key. It is disabled unless both `BUZZ_EMPLOYEE_NATIVE_SIGN_IN=1` and `BUZZ_EMPLOYEE_NATIVE_RELAY_URL=` are set by the operator. API and browser sender origins stay pinned to the Square BuilderLab staging service and Square staging Dashboard; selecting a community cannot choose an identity server. + +For an explicitly selected staging Playpen, set `BUZZ_EMPLOYEE_NATIVE_PLAYPEN_TARGET=`. This adds fixed `Baggage: kgoose-builderbot-playpen=` only to the pinned public redeem POST, auth-me GET and identity-assertion POST. It replaces ambient routing selection, refuses other origins/actions and preserves the exact signed public URL and body. Both native clients refuse redirects. It never targets the Auth0 token endpoint or a user-selected identity service; leave it unset for normal staging routing. + +The employee selects their employer's community and chooses **Sign in with Square**. Native code creates an S256 verifier, independent state and nonce, and a dynamic numeric IPv4 loopback listener. The employee uses their own Square account in the browser and explicitly continues. Only a one-use verifier-bound code and state enter the callback URL. Redeem refuses redirects, verifies the original session's subject and expiry through `/v1/auth/me`, and retains the credential only in the existing native BB session store. No session lifetime is extended. + +The desktop requests `/v1/buzz/identity/assertions` with that session and an exact NIP-98 proof signed by its own current Buzz key. That existing service enrolls an absent key and returns a short-lived key-bound relay assertion. The protected native WebSocket upgrade and central human relay HTTP paths attach the assertion while retaining NIP-42/NIP-98 proof. Credentials do not enter renderer command responses. Logout, replaced login, key changes and community changes cancel old authorization; short-lived evidence refresh is bounded. Other communities retain their existing transport. + +The initial sign-in surface appears before relay-dependent onboarding. A failed community admission remains retryable. A prepared workspace, received CLI credential, or key enrollment does not by itself mean the employee can message Managerbot. + +Before employee acceptance, apply the employee operation schema and server receipt schema, deploy the fenced membership and native handoff services, preserve enterprise revocation authorization time, configure the exact managed workspace and existing Auth0 CTE mount/client secret, and activate only the reviewed ingress and feature flags. The shared private org must retain both Buzz and BuilderLab USER capabilities. The dedicated workspace's `isBuilderlabEnabled=false` is not a denial of the BuilderLab product entitlement. + +Managerbot also needs authoritative same-employee key resolution and verified same-person delegated Square OAuth. Those are coordinated follow-up changes; do not seed an employee identity row manually or claim the full message/reply acceptance until they and the live configuration are validated. The client signs the actual external `/api/goose/v1/buzz/identity/assertions` URL; the server's configured proof URL must match exactly. + +Local validation uses real loopback HTTP/WebSocket boundaries plus a renderer journey with mocked native IPC; it does not claim live employee authentication. `just desktop-ci`, focused employee transport tests, the shared connector suite, typechecking and the size ratchet pass. Full `just ci` reaches unchanged mobile formatting/dependency errors in this checkout. A debug Tauri client builds through the supported package wrapper; local unit-test sidecar placeholders are not a production release bundle. diff --git a/desktop/src-tauri/src/builderlab.rs b/desktop/src-tauri/src/builderlab.rs index 1252946c58b..edf6257f49b 100644 --- a/desktop/src-tauri/src/builderlab.rs +++ b/desktop/src-tauri/src/builderlab.rs @@ -15,11 +15,39 @@ use url::Url; const BUILDERLAB_API_BASE_URL: &str = "https://app.builderlab.xyz/api/goose"; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); const BB_SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential"; +fn session_header(credential: &str) -> Result { + let mut value = reqwest::header::HeaderValue::from_str(credential) + .map_err(|_| "Invalid identity session")?; + value.set_sensitive(true); + Ok(value) +} + +/// A committed key/community change must close sockets using the previous employee session. +pub(crate) fn invalidate_changed_employee_context(state: &crate::app_state::AppState) { + use tauri::Manager; + let handle = state + .app_handle + .lock() + .ok() + .and_then(|handle| handle.clone()); + if let Some(handle) = handle { + if let Some(session) = handle.try_state::() { + let _ = session.request_context(state); + notify_session_changed(state); + } + } +} +pub(crate) fn notify_session_changed(state: &crate::app_state::AppState) { + use tauri::Emitter; + if let Some(handle) = state.app_handle.lock().ok().and_then(|value| value.clone()) { + let _ = handle.emit("builderlab-session-changed", ()); + } +} + // Builderlab enforces an Origin check on the identity bind endpoints. Browsers // attach this automatically; the desktop reqwest client must set it explicitly // or challenge/verify fail with `invalid_origin`. It also seeds the challenge // body's `origin` field so both agree. -const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz"; const AUTH_COMPLETE_HTML: &str = r#" @@ -140,7 +168,7 @@ const AUTH_COMPLETE_HTML: &str = r#" "#; #[derive(Default)] -pub(crate) struct BuilderlabSession(Mutex>); +pub(crate) struct BuilderlabSession(Mutex>, tokio::sync::Mutex<()>); #[derive(Default)] pub(crate) struct BuilderlabLogin(Mutex>); @@ -152,6 +180,197 @@ struct PendingLogin { struct StoredSession { credential: String, + api_base: &'static str, + employee: Option, + assertion: Option, + refusal: Option, + cancel: tokio_util::sync::CancellationToken, +} + +pub(crate) struct EmployeeBinding { + pub(crate) pubkey: String, + pub(crate) expires_at: i64, + pub(crate) relay: String, +} +struct CachedAssertion { + value: String, + expires_at: i64, +} + +mod employee_relay; +mod employee_response; +pub(crate) use employee_relay::{ + employee_evidence, requires_employee, send_employee_media_request, send_employee_request, + EmployeeEvidence, +}; +pub(crate) use employee_response::{ + employee_response_end, guard_employee_response, ProtectedAssertion, +}; + +impl BuilderlabSession { + pub(crate) fn employee_expiry(&self, state: &crate::app_state::AppState) -> Option { + self.request_context(state).ok().flatten()?; + let stored = self.0.lock().ok()?; + let stored = stored.as_ref()?; + // A received CLI credential alone is not a connected Buzz identity. + stored.assertion.as_ref()?; + stored.employee.as_ref().map(|employee| employee.expires_at) + } + pub(crate) fn invalidate_employee_assertion(&self) -> Result<(), String> { + if let Some(stored) = self + .0 + .lock() + .map_err(|_| "Square session unavailable")? + .as_mut() + { + stored.assertion = None; + } + Ok(()) + } + pub(crate) fn employee_can_retry(&self, state: &crate::app_state::AppState) -> bool { + if self.request_context(state).ok().flatten().is_none() { + return false; + } + self.0 + .lock() + .ok() + .and_then(|stored| { + stored.as_ref().map(|stored| { + stored.employee.is_some() + && (stored.refusal.is_some() || stored.assertion.is_none()) + }) + }) + .unwrap_or(false) + } + fn refuse_employee_access(&self, credential: &str, reason: String) -> Result<(), String> { + let mut stored = self.0.lock().map_err(|_| "Square session unavailable")?; + if let Some(stored) = stored + .as_mut() + .filter(|stored| stored.credential == credential) + { + stored.cancel.cancel(); + stored.assertion = None; + stored.refusal = Some(reason); + } + Ok(()) + } + pub(crate) fn clear_failed_employee_login( + &self, + credential: &str, + canceled: bool, + ) -> Result<(), String> { + let mut stored = self.0.lock().map_err(|_| "Square session unavailable")?; + if stored.as_ref().is_some_and(|value| { + value.credential == credential && (canceled || value.refusal.is_none()) + }) { + if let Some(previous) = stored.take() { + previous.cancel.cancel(); + } + } + Ok(()) + } + fn replace(&self, next: StoredSession) -> Result<(), String> { + let mut current = self.0.lock().map_err(|error| error.to_string())?; + if let Some(previous) = current.replace(next) { + previous.cancel.cancel(); + } + Ok(()) + } + fn clear(&self) -> Result<(), String> { + if let Some(previous) = self.0.lock().map_err(|error| error.to_string())?.take() { + previous.cancel.cancel(); + } + Ok(()) + } + pub(crate) fn clear_if_credential(&self, credential: &str) -> Result<(), String> { + let mut stored = self.0.lock().map_err(|_| "Square session unavailable")?; + if stored + .as_ref() + .is_some_and(|value| value.credential == credential) + { + if let Some(previous) = stored.take() { + previous.cancel.cancel(); + } + } + Ok(()) + } + + fn request_context( + &self, + app_state: &crate::app_state::AppState, + ) -> Result, String> { + let mut stored = self.0.lock().map_err(|error| error.to_string())?; + if let Some(session) = stored.as_ref() { + if let Some(expected) = &session.employee { + if app_state.signing_keys()?.public_key().to_hex() != expected.pubkey + || chrono::Utc::now().timestamp() >= expected.expires_at + || crate::relay::relay_ws_url_with_override(app_state) != expected.relay + { + session.cancel.cancel(); + *stored = None; + return Err("Buzz identity changed. Sign in with Square again.".to_owned()); + } + } + } + Ok(stored + .as_ref() + .map(|session| (session.credential.clone(), session.api_base))) + } + + fn api_origin(&self, app_state: &crate::app_state::AppState) -> Result { + let (_, base) = self.request_context(app_state)?.ok_or("Sign in first")?; + Url::parse(base) + .map(|url| url.origin().ascii_serialization()) + .map_err(|_| "Invalid identity origin".to_owned()) + } + + pub(crate) fn finish_employee_login( + &self, + login: &BuilderlabLogin, + id: uuid::Uuid, + credential: String, + api_base: &'static str, + employee: EmployeeBinding, + ) -> Result<(), String> { + let pending = login.0.lock().map_err(|error| error.to_string())?; + if pending.as_ref().is_none_or(|pending| pending.id != id) { + return Err("Square sign-in canceled".to_owned()); + } + self.replace(StoredSession { + credential, + api_base, + employee: Some(employee), + assertion: None, + refusal: None, + cancel: tokio_util::sync::CancellationToken::new(), + })?; + // The login remains cancellable through first assertion/key enrollment. + Ok(()) + } +} + +impl BuilderlabLogin { + pub(crate) fn complete_employee_login(&self, id: uuid::Uuid) -> Result<(), String> { + let mut pending = self.0.lock().map_err(|_| "Square sign-in unavailable")?; + if pending.as_ref().is_none_or(|value| value.id != id) { + return Err("Square sign-in canceled".to_owned()); + } + *pending = None; + Ok(()) + } + + pub(crate) fn begin_employee_login( + &self, + ) -> Result<(uuid::Uuid, oneshot::Receiver<()>), String> { + let id = uuid::Uuid::new_v4(); + let (cancel, receiver) = oneshot::channel(); + let mut pending = self.0.lock().map_err(|error| error.to_string())?; + if let Some(previous) = pending.take() { + let _ = previous.cancel.send(()); + } + *pending = Some(PendingLogin { id, cancel }); + Ok((id, receiver)) + } } #[derive(Debug, Deserialize)] @@ -163,16 +382,17 @@ struct LoginExchangeResponse { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct BuilderlabAuthInfo { - expires_at: String, - email: Option, - name: Option, + pub(crate) expires_at: String, + pub(crate) email: Option, + pub(crate) name: Option, } #[derive(Debug, Deserialize)] -struct AuthMeResponse { +pub(crate) struct AuthMeResponse { + pub(crate) subject: Option, email: Option, name: Option, - expires_at: String, + pub(crate) expires_at: String, } struct CallbackState { @@ -210,7 +430,11 @@ async fn login_callback( } fn api_url(path: &str) -> Result { - Url::parse(&format!("{BUILDERLAB_API_BASE_URL}{path}")) + api_url_for_base(BUILDERLAB_API_BASE_URL, path) +} + +pub(crate) fn api_url_for_base(base: &str, path: &str) -> Result { + Url::parse(&format!("{base}{path}")) .map_err(|error| format!("invalid Builderlab API URL: {error}")) } @@ -224,27 +448,70 @@ fn login_url(return_to: &str) -> Result { Ok(login_url) } -async fn authenticated_user( +enum SessionCheckError { + Rejected(String), + Unavailable(String), +} + +impl From for SessionCheckError { + fn from(message: String) -> Self { + Self::Unavailable(message) + } +} + +impl SessionCheckError { + fn message(self) -> String { + match self { + Self::Rejected(message) | Self::Unavailable(message) => message, + } + } +} + +pub(crate) async fn authenticated_user( client: &reqwest::Client, credential: &str, + api_base: &str, ) -> Result { + check_authenticated_user(client, credential, api_base) + .await + .map_err(SessionCheckError::message) +} + +async fn check_authenticated_user( + client: &reqwest::Client, + credential: &str, + api_base: &str, +) -> Result { + let builder = client + .get(api_url_for_base(api_base, "/v1/auth/me")?) + .header(BB_SESSION_CREDENTIAL_HEADER, session_header(credential)?) + .timeout(Duration::from_secs(30)); + let request = if api_base == crate::employee_login::API_BASE { + crate::employee_api::request(builder)? + } else { + builder + .build() + .map_err(|_| "Could not prepare Builderlab session check".to_owned())? + }; let response = client - .get(api_url("/v1/auth/me")?) - .header(BB_SESSION_CREDENTIAL_HEADER, credential) - .timeout(Duration::from_secs(30)) - .send() + .execute(request) .await .map_err(|error| format!("Builderlab session check failed: {error}"))?; if !response.status().is_success() { - return Err(format!( + let message = format!( "Builderlab session check failed with HTTP {}", response.status() - )); + ); + return Err(match response.status() { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + SessionCheckError::Rejected(message) + } + _ => SessionCheckError::Unavailable(message), + }); } - response - .json() - .await - .map_err(|error| format!("invalid Builderlab session response: {error}")) + response.json().await.map_err(|error| { + SessionCheckError::Unavailable(format!("invalid Builderlab session response: {error}")) + }) } #[tauri::command] @@ -339,7 +606,12 @@ pub(crate) async fn start_builderlab_login( return Err("Builderlab code exchange returned an empty credential".to_owned()); } - let me = authenticated_user(&app_state.http_client, &exchanged.session_credential).await?; + let me = authenticated_user( + &app_state.media_fetch_client, + &exchanged.session_credential, + BUILDERLAB_API_BASE_URL, + ) + .await?; if exchanged.expires_at != me.expires_at { return Err("Builderlab session expiry did not match code exchange".to_owned()); } @@ -356,11 +628,17 @@ pub(crate) async fn start_builderlab_login( { return Err("Builderlab authentication canceled".to_owned()); } + session.replace(StoredSession { + credential: exchanged.session_credential, + api_base: BUILDERLAB_API_BASE_URL, + employee: None, + assertion: None, + refusal: None, + cancel: tokio_util::sync::CancellationToken::new(), + })?; *pending = None; } - *session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession { - credential: exchanged.session_credential, - }); + notify_session_changed(&app_state); Ok(info) } @@ -369,27 +647,31 @@ pub(crate) async fn get_builderlab_auth( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result, String> { - let stored = session - .0 - .lock() - .map_err(|error| error.to_string())? - .as_ref() - .map(|stored| stored.credential.clone()); - let Some(credential) = stored else { + check_stored_auth(&app_state, &session).await +} + +async fn check_stored_auth( + app_state: &crate::app_state::AppState, + session: &BuilderlabSession, +) -> Result, String> { + let stored = session.request_context(app_state)?; + let Some((credential, api_base)) = stored else { return Ok(None); }; - match authenticated_user(&app_state.http_client, &credential).await { + match check_authenticated_user(&app_state.media_fetch_client, &credential, api_base).await { Ok(me) => Ok(Some(BuilderlabAuthInfo { expires_at: me.expires_at, email: me.email, name: me.name, })), Err(error) => { - *session - .0 - .lock() - .map_err(|lock_error| lock_error.to_string())? = None; - Err(error) + // Losing reachability does not revoke a credential. Keep it for a later + // retry; only an authoritative authentication refusal ends the session. + if matches!(&error, SessionCheckError::Rejected(_)) { + session.clear_if_credential(&credential)?; + notify_session_changed(app_state); + } + Err(error.message()) } } } @@ -407,8 +689,14 @@ pub(crate) fn cancel_builderlab_login( #[tauri::command] pub(crate) fn clear_builderlab_auth( session: tauri::State<'_, BuilderlabSession>, + login: tauri::State<'_, BuilderlabLogin>, + app_state: tauri::State<'_, crate::app_state::AppState>, ) -> Result<(), String> { - *session.0.lock().map_err(|error| error.to_string())? = None; + if let Some(pending) = login.0.lock().map_err(|error| error.to_string())?.take() { + let _ = pending.cancel.send(()); + } + session.clear()?; + notify_session_changed(&app_state); Ok(()) } @@ -422,23 +710,24 @@ struct NostrIdentityChallenge { } async fn authenticated_json( - client: &reqwest::Client, + app_state: &crate::app_state::AppState, session: &BuilderlabSession, method: reqwest::Method, path: &str, body: serde_json::Value, ) -> Result { - let credential = session - .0 - .lock() - .map_err(|error| error.to_string())? - .as_ref() - .map(|stored| stored.credential.clone()) - .ok_or_else(|| "Sign in to Builderlab first".to_owned())?; - let response = client - .request(method, api_url(path)?) - .header(BB_SESSION_CREDENTIAL_HEADER, credential) - .header(reqwest::header::ORIGIN, BUILDERLAB_ORIGIN) + let (credential, api_base) = session + .request_context(app_state)? + .ok_or_else(|| "Sign in first".to_owned())?; + let origin = Url::parse(api_base) + .map_err(|_| "Invalid identity origin")? + .origin() + .ascii_serialization(); + let response = app_state + .media_fetch_client + .request(method, api_url_for_base(api_base, path)?) + .header(BB_SESSION_CREDENTIAL_HEADER, session_header(&credential)?) + .header(reqwest::header::ORIGIN, &origin) .json(&body) .timeout(Duration::from_secs(60)) .send() @@ -469,7 +758,7 @@ pub(crate) async fn get_builderlab_nostr_identity( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/current", @@ -484,11 +773,11 @@ pub(crate) async fn bind_builderlab_nostr_identity( session: tauri::State<'_, BuilderlabSession>, ) -> Result { let challenge_value = authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/challenge", - serde_json::json!({ "origin": BUILDERLAB_ORIGIN }), + serde_json::json!({ "origin": session.api_origin(&app_state)? }), ) .await?; // A structured error here (e.g. missing_mapping) arrives as an object with an @@ -510,7 +799,7 @@ pub(crate) async fn bind_builderlab_nostr_identity( &challenge.expires_at, )?; authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/verify", @@ -529,7 +818,7 @@ pub(crate) async fn delete_builderlab_nostr_identity( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/delete", @@ -544,7 +833,7 @@ pub(crate) async fn list_builderlab_communities( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/communities/list", @@ -560,7 +849,7 @@ pub(crate) async fn check_builderlab_community_name( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/communities/availability", @@ -576,7 +865,7 @@ pub(crate) async fn create_builderlab_community( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/communities", @@ -592,7 +881,7 @@ pub(crate) async fn archive_builderlab_community( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/communities/archive", @@ -608,7 +897,7 @@ pub(crate) async fn unarchive_builderlab_community( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/communities/unarchive", @@ -628,7 +917,7 @@ pub(crate) async fn transfer_builderlab_community( // archive/unarchive endpoints which take `community_id`; mirror the web // client's payload exactly. authenticated_json( - &app_state.http_client, + &app_state, &session, reqwest::Method::POST, "/v1/buzz/communities/transfer", @@ -644,6 +933,165 @@ pub(crate) async fn transfer_builderlab_community( mod tests { use super::*; + #[tokio::test] + async fn stored_session_survives_transient_checks_but_not_authentication_refusal() { + for status in [ + StatusCode::OK, // Malformed successful responses are not revocation evidence. + StatusCode::TOO_MANY_REQUESTS, + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, + StatusCode::UNAUTHORIZED, + StatusCode::FORBIDDEN, + ] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = + Box::leak(format!("http://{}", listener.local_addr().unwrap()).into_boxed_str()); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/v1/auth/me", + get(move || async move { (status, "invalid JSON") }), + ), + ) + .await + .unwrap(); + }); + let state = crate::app_state::build_app_state(); + let session = BuilderlabSession::default(); + let cancel = tokio_util::sync::CancellationToken::new(); + session + .replace(StoredSession { + credential: "session-fixture".to_owned(), + api_base: base, + employee: None, + assertion: None, + refusal: None, + cancel: cancel.clone(), + }) + .unwrap(); + assert!(check_stored_auth(&state, &session).await.is_err()); + let rejected = matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN); + assert_eq!( + session.request_context(&state).unwrap().is_none(), + rejected, + "{status}" + ); + assert_eq!(cancel.is_cancelled(), rejected, "{status}"); + server.abort(); + } + } + + #[tokio::test] + async fn unreachable_session_endpoint_preserves_the_stored_credential() { + // Close a reserved port without starting an HTTP server, so this request + // cannot reuse a live pooled connection from a preceding response. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = Box::leak(format!("http://{}", listener.local_addr().unwrap()).into_boxed_str()); + drop(listener); + let state = crate::app_state::build_app_state(); + let session = BuilderlabSession::default(); + let cancel = tokio_util::sync::CancellationToken::new(); + session + .replace(StoredSession { + credential: "session-fixture".to_owned(), + api_base: base, + employee: None, + assertion: None, + refusal: None, + cancel: cancel.clone(), + }) + .unwrap(); + assert!(check_stored_auth(&state, &session).await.is_err()); + assert_eq!( + session.request_context(&state).unwrap().unwrap().0, + "session-fixture" + ); + assert!(!cancel.is_cancelled()); + } + + #[test] + fn employee_session_retains_origin_and_rejects_canceled_or_replaced_identity() { + let state = crate::app_state::build_app_state(); + let keys = state.signing_keys().unwrap(); + let session = BuilderlabSession::default(); + let login = BuilderlabLogin::default(); + let (old, mut canceled) = login.begin_employee_login().unwrap(); + let (current, _) = login.begin_employee_login().unwrap(); + assert!(canceled.try_recv().is_ok()); + assert!(session + .finish_employee_login( + &login, + old, + "old".to_owned(), + "https://square.builderlabstaging.xyz/api/goose", + EmployeeBinding { + pubkey: keys.public_key().to_hex(), + expires_at: chrono::Utc::now().timestamp() + 240, + relay: crate::relay::relay_ws_url_with_override(&state), + }, + ) + .is_err()); + session + .finish_employee_login( + &login, + current, + "current".to_owned(), + "https://square.builderlabstaging.xyz/api/goose", + EmployeeBinding { + pubkey: keys.public_key().to_hex(), + expires_at: chrono::Utc::now().timestamp() + 240, + relay: crate::relay::relay_ws_url_with_override(&state), + }, + ) + .unwrap(); + let (credential, base) = session.request_context(&state).unwrap().unwrap(); + assert_eq!(credential, "current"); + assert_eq!(base, "https://square.builderlabstaging.xyz/api/goose"); + *state.keys.lock().unwrap() = nostr::Keys::generate(); + assert!(session.request_context(&state).is_err()); + assert!(session.request_context(&state).unwrap().is_none()); + } + #[test] + fn employee_provisional_login_remains_cancelable_and_cannot_clear_a_replacement() { + let state = crate::app_state::build_app_state(); + let session = BuilderlabSession::default(); + let login = BuilderlabLogin::default(); + let (id, mut cancellation) = login.begin_employee_login().unwrap(); + session + .finish_employee_login( + &login, + id, + "provisional".to_owned(), + BUILDERLAB_API_BASE_URL, + EmployeeBinding { + pubkey: state.signing_keys().unwrap().public_key().to_hex(), + expires_at: chrono::Utc::now().timestamp() + 240, + relay: crate::relay::relay_ws_url_with_override(&state), + }, + ) + .unwrap(); + // A later login/cancel still owns and invalidates this in-flight admission. + let (new_id, _) = login.begin_employee_login().unwrap(); + assert!(cancellation.try_recv().is_ok()); + assert!(login.complete_employee_login(id).is_err()); + session + .replace(StoredSession { + credential: "replacement".to_owned(), + api_base: BUILDERLAB_API_BASE_URL, + employee: None, + assertion: None, + refusal: None, + cancel: tokio_util::sync::CancellationToken::new(), + }) + .unwrap(); + session.clear_if_credential("provisional").unwrap(); + assert_eq!( + session.request_context(&state).unwrap().unwrap().0, + "replacement" + ); + login.complete_employee_login(new_id).unwrap(); + } #[test] fn auth_complete_page_uses_buzz_brand() { for expected in [ diff --git a/desktop/src-tauri/src/builderlab/employee_relay.rs b/desktop/src-tauri/src/builderlab/employee_relay.rs new file mode 100644 index 00000000000..66f6b07bb60 --- /dev/null +++ b/desktop/src-tauri/src/builderlab/employee_relay.rs @@ -0,0 +1,370 @@ +//! Exact configured-relay transport. Session and assertion credentials remain native. +use super::*; +use nostr::Keys; +use tauri::Manager; +use tokio_util::sync::CancellationToken; + +pub(crate) struct EmployeeEvidence { + pub(crate) header: reqwest::header::HeaderValue, + pub(crate) cancel: CancellationToken, + pub(crate) expires_at: i64, +} + +pub(crate) fn requires_employee(value: &str) -> Result { + let Some(configured) = crate::employee_login::configured_relay()? else { + return Ok(false); + }; + let parsed = Url::parse(value).map_err(|_| "Invalid relay URL")?; + let authority = &parsed[url::Position::BeforeHost..url::Position::AfterPort]; + if format!("wss://{authority}") != configured { + return Ok(false); + } + if !matches!(parsed.scheme(), "https" | "wss") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return Err("Employee relay requires its exact TLS origin".to_owned()); + } + Ok(true) +} + +/// Refuses operator sessions and other signing keys on the configured employee relay. +pub(crate) async fn employee_evidence( + app_state: &crate::app_state::AppState, + keys: &Keys, + relay_or_url: &str, +) -> Result, String> { + if !requires_employee(relay_or_url)? { + return Ok(None); + } + let configured = + crate::employee_login::configured_relay()?.ok_or("Square relay is not configured")?; + if crate::relay::relay_ws_url_with_override(app_state) != configured + || app_state.signing_keys()?.public_key() != keys.public_key() + { + return Err("Employee community or Buzz identity changed".to_owned()); + } + let handle = app_state + .app_handle + .lock() + .map_err(|_| "Identity service unavailable")? + .clone() + .ok_or("Identity service unavailable")?; + let session = handle.state::(); + session + .evidence(app_state, keys, &configured) + .await + .map(Some) +} + +impl BuilderlabSession { + async fn evidence( + &self, + app_state: &crate::app_state::AppState, + keys: &Keys, + relay: &str, + ) -> Result { + self.evidence_with_refresh(app_state, keys, relay, false) + .await + } + + pub(crate) async fn evidence_with_refresh( + &self, + app_state: &crate::app_state::AppState, + keys: &Keys, + relay: &str, + force: bool, + ) -> Result { + let _issuance = self.1.lock().await; + if force { + self.invalidate_employee_assertion()?; + let mut stored = self.0.lock().map_err(|_| "Square session unavailable")?; + if let Some(stored) = stored.as_mut() { + if stored.refusal.take().is_some() { + stored.cancel = CancellationToken::new(); + } + } + } + if crate::relay::relay_ws_url_with_override(app_state) != relay { + return Err("Employee community changed".to_owned()); + } + let (credential, base) = self + .request_context(app_state)? + .ok_or("Sign in with Square for this community")?; + let generation = app_state + .workspace_apply_generation + .load(std::sync::atomic::Ordering::Acquire); + let (expires_at, cancel) = { + let stored = self.0.lock().map_err(|_| "Square session unavailable")?; + let stored = stored.as_ref().ok_or("Sign in with Square")?; + if let Some(reason) = &stored.refusal { + return Err(reason.clone()); + } + let binding = stored + .employee + .as_ref() + .ok_or("A Square employee session is required")?; + if binding.pubkey != keys.public_key().to_hex() { + return Err("Square session does not authorize this Buzz key".to_owned()); + } + if let Some(assertion) = stored + .assertion + .as_ref() + .filter(|value| value.expires_at > chrono::Utc::now().timestamp() + 10) + { + return evidence( + &assertion.value, + assertion.expires_at, + stored.cancel.clone(), + ); + } + (binding.expires_at, stored.cancel.clone()) + }; + let url = api_url_for_base(base, "/v1/buzz/identity/assertions")?; + let body = serde_json::to_vec( + &serde_json::json!({"relay_url":relay,"nostr_pubkey":keys.public_key().to_hex()}), + ) + .map_err(|_| "Invalid assertion request")?; + let proof = crate::relay::build_nip98_auth_header_for_keys( + keys, + &reqwest::Method::POST, + url.as_str(), + &body, + )?; + let request = app_state + .media_fetch_client + .post(url) + .header(BB_SESSION_CREDENTIAL_HEADER, session_header(&credential)?) + .header("Authorization", proof) + .header("Content-Type", "application/json") + .body(body) + .timeout(Duration::from_secs(30)); + let request = crate::employee_api::request(request)?; + let response = tokio::select! { + _ = cancel.cancelled() => return Err("Square sign-in changed".to_owned()), + result = app_state.media_fetch_client.execute(request) => result.map_err(|_| "Could not request employee relay access")?, + }; + let status = response.status(); + let mut response = response; + let mut bytes = Vec::new(); + while let Some(chunk) = tokio::select! { + _ = cancel.cancelled() => return Err("Square sign-in changed".to_owned()), + result = response.chunk() => result.map_err(|_| "Could not read relay authorization")?, + } { + if bytes.len() + chunk.len() > 32 * 1024 { + return Err("Relay authorization response is too large".to_owned()); + } + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + let code = serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("error") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }); + match code.as_deref() { + Some("session_required" | "session_expired") => { + self.clear_if_credential(&credential)?; + super::notify_session_changed(app_state); + } + Some("binding_mismatch" | "authorization_denied") => { + self.refuse_employee_access(&credential, "Square access is suspended. Use Retry community connection after your employer resolves access.".to_owned())?; + super::notify_session_changed(app_state); + } + _ => {} + } + return Err(match code.as_deref() { + Some("binding_mismatch") => "This Square account is linked to another Buzz key. Use that Buzz identity or ask your employer to resolve the link.", + Some("authorization_denied") => "Square access is not prepared or has been disabled. Ask your employer to check your access.", + Some("session_required" | "session_expired") => "Your Square session expired. Sign in again.", + Some("rate_limited") => "Too many access attempts. Wait a moment and retry.", + Some("invalid_proof") => "Could not verify this Buzz key. Check your device clock and retry.", + _ => "Square access is temporarily unavailable. Retry in a moment.", + }.to_owned()); + } + #[derive(Deserialize)] + struct Receipt { + assertion: String, + nostr_pubkey: String, + expires_at: i64, + } + let receipt: Receipt = + serde_json::from_slice(&bytes).map_err(|_| "Invalid relay authorization response")?; + let now = chrono::Utc::now().timestamp(); + if receipt.nostr_pubkey != keys.public_key().to_hex() + || receipt.expires_at <= now + || receipt.expires_at > now + 300 + || receipt.expires_at > expires_at + || cancel.is_cancelled() + || app_state + .workspace_apply_generation + .load(std::sync::atomic::Ordering::Acquire) + != generation + || crate::relay::relay_ws_url_with_override(app_state) != relay + || app_state.signing_keys()?.public_key() != keys.public_key() + { + return Err("Employee relay authorization changed".to_owned()); + } + let result = evidence(&receipt.assertion, receipt.expires_at, cancel)?; + let mut stored = self.0.lock().map_err(|_| "Square session unavailable")?; + let stored = stored + .as_mut() + .filter(|stored| stored.credential == credential) + .ok_or("Square session changed")?; + stored.assertion = Some(CachedAssertion { + value: receipt.assertion, + expires_at: receipt.expires_at, + }); + Ok(result) + } +} +fn evidence( + value: &str, + expires_at: i64, + cancel: CancellationToken, +) -> Result { + if value.len() > 16 * 1024 + || (value.split('.').count() != 3 || value.split('.').any(str::is_empty)) + || value + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))) + { + return Err("Invalid relay assertion".to_owned()); + } + let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {value}")) + .map_err(|_| "Invalid relay assertion")?; + header.set_sensitive(true); + Ok(EmployeeEvidence { + header, + cancel, + expires_at, + }) +} + +/// Ordinary public media keeps its existing behavior, including identity recovery mode. +pub(crate) async fn send_employee_media_request( + state: &crate::app_state::AppState, + url: &str, + request: reqwest::RequestBuilder, +) -> Result { + if !requires_employee(url)? { + return request + .send() + .await + .map_err(|error| crate::relay::classify_request_error(&error)); + } + let keys = state.signing_keys()?; + send_employee_request(state, &keys, url, request).await +} + +/// Adds only scoped relay evidence. The request's own NIP-98 proof is preserved. +pub(crate) async fn send_employee_request( + app_state: &crate::app_state::AppState, + keys: &Keys, + url: &str, + request: reqwest::RequestBuilder, +) -> Result { + if !requires_employee(url)? { + return request + .send() + .await + .map_err(|error| crate::relay::classify_request_error(&error)); + } + let (_, request) = request.build_split(); + let mut request = request.map_err(|_| "Invalid employee relay request")?; + let timeout = request + .timeout() + .copied() + .unwrap_or(Duration::from_secs(30)); + let started = tokio::time::Instant::now(); + tokio::time::timeout(timeout, async { + let evidence = employee_evidence(app_state, keys, url) + .await? + .ok_or("Employee relay evidence is required")?; + refresh_nip98_request(&mut request, keys)?; + // Keep response-body consumption inside the original operation budget too. + *request.timeout_mut() = Some(timeout.saturating_sub(started.elapsed())); + // This client never follows redirects, including media request builders. + send_authorized_request(app_state.media_fetch_client.clone(), request, evidence).await + }) + .await + .map_err(|_| "relay unreachable: request timed out".to_owned())? +} + +fn refresh_nip98_request(request: &mut reqwest::Request, keys: &Keys) -> Result<(), String> { + use base64::Engine; + let encoded = request + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Nostr ")); + let nip98 = encoded + .and_then(|value| { + base64::engine::general_purpose::STANDARD + .decode(value) + .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value)) + .ok() + }) + .and_then(|value| serde_json::from_slice::(&value).ok()) + .is_some_and(|value| value["kind"].as_u64() == Some(27235)); + if nip98 { + let body = match request.body() { + Some(body) => body + .as_bytes() + .ok_or("Cannot sign a streaming NIP-98 request")?, + None => &[], + }; + let proof = crate::relay::build_nip98_auth_header_for_keys( + keys, + request.method(), + request.url().as_str(), + body, + )?; + request.headers_mut().insert( + "Authorization", + proof.parse().map_err(|_| "Invalid relay proof")?, + ); + } + Ok(()) +} + +async fn send_authorized_request( + client: reqwest::Client, + mut request: reqwest::Request, + evidence: EmployeeEvidence, +) -> Result { + let assertion = evidence + .header + .to_str() + .map_err(|_| "Invalid relay assertion")? + .trim_start_matches("Bearer ") + .to_owned(); + request + .headers_mut() + .insert("Nostr-Federated-Identity", evidence.header); + let remaining = Duration::from_secs( + evidence + .expires_at + .saturating_sub(chrono::Utc::now().timestamp()) + .max(0) as u64, + ); + let mut response = tokio::select! { + biased; + _ = evidence.cancel.cancelled()=>return Err("Square sign-in changed".to_owned()), + _ = tokio::time::sleep(remaining)=>return Err("Employee relay authorization expired".to_owned()), + response=client.execute(request)=>response.map_err(|error|crate::relay::classify_request_error(&error))?, + }; + response.extensions_mut().insert(ProtectedAssertion( + assertion, + evidence.cancel, + evidence.expires_at, + )); + super::employee_response::filter_response(response) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/builderlab/employee_relay/tests.rs b/desktop/src-tauri/src/builderlab/employee_relay/tests.rs new file mode 100644 index 00000000000..ee49d026482 --- /dev/null +++ b/desktop/src-tauri/src/builderlab/employee_relay/tests.rs @@ -0,0 +1,572 @@ +use super::*; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Router, +}; +use base64::Engine as _; +use sha2::{Digest, Sha256}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Notify; + +#[derive(Default)] +struct Stub { + captured: Mutex)>>, + response: Mutex, + entered: Notify, + release: Notify, + hold: bool, + redirect: bool, + status: Mutex, +} +async fn issue( + State(stub): State>, + headers: HeaderMap, + body: Bytes, +) -> axum::response::Response { + stub.captured.lock().unwrap().push((headers, body.to_vec())); + stub.entered.notify_one(); + if stub.hold { + stub.release.notified().await; + } + if stub.redirect { + return ( + StatusCode::TEMPORARY_REDIRECT, + [("Location", "/must-not-receive-credential")], + ) + .into_response(); + } + ( + *stub.status.lock().unwrap(), + axum::Json(stub.response.lock().unwrap().clone()), + ) + .into_response() +} +struct Fixture { + state: Arc, + session: Arc, + stub: Arc, + task: tokio::task::JoinHandle<()>, + relay: String, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Fixture { + async fn new(hold: bool, redirect: bool) -> Self { + let state = Arc::new(crate::app_state::build_app_state()); + let keys = state.signing_keys().unwrap(); + let relay = "wss://employee.example".to_owned(); + *state.relay_url_override.lock().unwrap() = Some(relay.clone()); + let stub = Arc::new(Stub { + hold, + redirect, + ..Stub::default() + }); + *stub.response.lock().unwrap() = serde_json::json!({"assertion":"test.assertion.signature","nostr_pubkey":keys.public_key().to_hex(),"expires_at":chrono::Utc::now().timestamp()+120}); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base: &'static str = Box::leak( + format!("http://{}/api/goose", listener.local_addr().unwrap()).into_boxed_str(), + ); + let app = Router::new() + .route("/api/goose/v1/buzz/identity/assertions", post(issue)) + .route("/must-not-receive-credential", post(issue)) + .route("/query", post(issue)) + .with_state(stub.clone()); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let session = Arc::new(BuilderlabSession::default()); + session + .replace(StoredSession { + credential: "private-session-fixture".to_owned(), + api_base: base, + employee: Some(EmployeeBinding { + pubkey: keys.public_key().to_hex(), + expires_at: chrono::Utc::now().timestamp() + 240, + relay: relay.clone(), + }), + assertion: None, + refusal: None, + cancel: CancellationToken::new(), + }) + .unwrap(); + Self { + state, + session, + stub, + task, + relay, + } + } + async fn evidence(&self) -> Result { + self.session + .evidence( + &self.state, + &self.state.signing_keys().unwrap(), + &self.relay, + ) + .await + } +} + +#[tokio::test] +async fn employee_assertion_uses_exact_own_key_proof_and_caches_only_scoped_native_evidence() { + let fixture = Fixture::new(false, false).await; + assert!( + fixture.session.employee_expiry(&fixture.state).is_none(), + "a CLI receipt alone is not connected Buzz access" + ); + let evidence = fixture.evidence().await.unwrap(); + assert!(fixture.session.employee_expiry(&fixture.state).is_some()); + assert!(evidence.header.is_sensitive()); + assert_eq!(evidence.header, "Bearer test.assertion.signature"); + fixture.evidence().await.unwrap(); + let captured = fixture.stub.captured.lock().unwrap(); + assert_eq!( + captured.len(), + 1, + "the same employee/key/relay reuses its short-lived assertion" + ); + let (headers, body) = &captured[0]; + assert_eq!( + headers[BB_SESSION_CREDENTIAL_HEADER], + "private-session-fixture" + ); + let proof = headers["Authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let proof = base64::engine::general_purpose::STANDARD + .decode(proof) + .unwrap(); + let event: nostr::Event = serde_json::from_slice(&proof).unwrap(); + event.verify().unwrap(); + assert_eq!( + event.pubkey, + fixture.state.signing_keys().unwrap().public_key() + ); + let event: serde_json::Value = serde_json::from_slice(&proof).unwrap(); + let tags = event["tags"].as_array().unwrap(); + let base = fixture + .session + .request_context(&fixture.state) + .unwrap() + .unwrap() + .1; + assert!(tags.contains(&serde_json::json!([ + "u", + format!("{base}/v1/buzz/identity/assertions") + ]))); + assert!(tags.contains(&serde_json::json!(["method", "POST"]))); + assert!(tags.contains(&serde_json::json!([ + "payload", + hex::encode(Sha256::digest(body)) + ]))); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["relay_url"], fixture.relay); + assert_eq!(body["nostr_pubkey"], event["pubkey"]); +} + +#[tokio::test] +async fn employee_assertion_refuses_wrong_key_or_expiry_without_caching() { + for mutation in ["key", "expired", "beyond-session", "malformed"] { + let fixture = Fixture::new(false, false).await; + { + let mut response = fixture.stub.response.lock().unwrap(); + match mutation { + "key" => response["nostr_pubkey"] = "00".repeat(32).into(), + "expired" => response["expires_at"] = (chrono::Utc::now().timestamp() - 1).into(), + "beyond-session" => { + response["expires_at"] = (chrono::Utc::now().timestamp() + 280).into() + } + _ => response["assertion"] = "not a JWT".into(), + } + } + assert!(fixture.evidence().await.is_err(), "{mutation}"); + assert!(fixture + .session + .0 + .lock() + .unwrap() + .as_ref() + .unwrap() + .assertion + .is_none()); + } +} + +#[tokio::test] +async fn sign_out_cancels_an_inflight_assertion_and_never_restores_its_cache() { + let fixture = Fixture::new(true, false).await; + let request = fixture.evidence(); + tokio::pin!(request); + tokio::select! { _ = fixture.stub.entered.notified()=>{}, result = &mut request => panic!("premature result: {}",result.is_ok()) } + fixture.session.clear().unwrap(); + assert!(tokio::time::timeout(Duration::from_secs(1), request) + .await + .unwrap() + .is_err()); + assert!(fixture.session.0.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn community_switch_during_issuance_refuses_the_old_response() { + let fixture = Fixture::new(true, false).await; + let request = fixture.evidence(); + tokio::pin!(request); + tokio::select! { _ = fixture.stub.entered.notified()=>{}, result = &mut request => panic!("premature result: {}",result.is_ok()) } + fixture + .state + .workspace_apply_generation + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + fixture.stub.release.notify_one(); + assert!(request.await.is_err()); + assert!(fixture + .session + .0 + .lock() + .unwrap() + .as_ref() + .unwrap() + .assertion + .is_none()); +} + +#[tokio::test] +async fn assertion_redirect_is_refused_without_forwarding_the_session() { + let fixture = Fixture::new(false, true).await; + assert!(fixture.evidence().await.is_err()); + assert_eq!(fixture.stub.captured.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn employee_authorization_denial_explains_recovery_without_echoing_the_response() { + let fixture = Fixture::new(false, false).await; + *fixture.stub.status.lock().unwrap() = StatusCode::FORBIDDEN; + *fixture.stub.response.lock().unwrap() = + serde_json::json!({"error":"binding_mismatch","diagnostic":"private-session-fixture"}); + let error = fixture.evidence().await.err().unwrap(); + assert!(error.contains("another Buzz key")); + assert!(!error.contains("private-session-fixture")); + assert!(fixture.session.employee_expiry(&fixture.state).is_none()); + assert!(fixture.session.employee_can_retry(&fixture.state)); + assert!(fixture.evidence().await.is_err()); + assert_eq!( + fixture.stub.captured.lock().unwrap().len(), + 1, + "refused access must not automatically contact the issuer again" + ); + *fixture.stub.status.lock().unwrap() = StatusCode::OK; + let keys = fixture.state.signing_keys().unwrap(); + *fixture.stub.response.lock().unwrap() = serde_json::json!({"assertion":"test.assertion.signature","nostr_pubkey":keys.public_key().to_hex(),"expires_at":chrono::Utc::now().timestamp()+120}); + let evidence = fixture + .session + .evidence_with_refresh(&fixture.state, &keys, &fixture.relay, true) + .await + .unwrap(); + assert!(!evidence.cancel.is_cancelled()); + assert!(!fixture.session.employee_can_retry(&fixture.state)); + assert_eq!(fixture.stub.captured.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn committed_identity_or_community_change_cancels_previous_employee_evidence() { + for change_key in [true, false] { + let fixture = Fixture::new(false, false).await; + let evidence = fixture.evidence().await.unwrap(); + if change_key { + *fixture.state.keys.lock().unwrap() = nostr::Keys::generate(); + } else { + *fixture.state.relay_url_override.lock().unwrap() = + Some("wss://other.example".to_owned()); + } + assert!(fixture.session.request_context(&fixture.state).is_err()); + assert!(evidence.cancel.is_cancelled()); + assert!(fixture.session.0.lock().unwrap().is_none()); + } +} + +#[tokio::test] +async fn relay_http_preserves_own_nip98_proof_without_forwarding_the_bb_session() { + let fixture = Fixture::new(false, false).await; + let evidence = fixture.evidence().await.unwrap(); + let base = fixture + .session + .request_context(&fixture.state) + .unwrap() + .unwrap() + .1; + let origin = Url::parse(base).unwrap().origin().ascii_serialization(); + let url = format!("{origin}/query"); + let body = br#"[{"kinds":[9]}]"#.to_vec(); + let keys = fixture.state.signing_keys().unwrap(); + let proof = + crate::relay::build_nip98_auth_header_for_keys(&keys, &reqwest::Method::POST, &url, &body) + .unwrap(); + let request = fixture + .state + .media_fetch_client + .post(&url) + .header("Authorization", &proof) + .body(body.clone()); + let (client, request) = request.build_split(); + assert!(send_authorized_request(client, request.unwrap(), evidence) + .await + .unwrap() + .status() + .is_success()); + let captured = fixture.stub.captured.lock().unwrap(); + assert_eq!(captured.len(), 2); + let (headers, actual_body) = &captured[1]; + assert_eq!( + headers.get_all("Nostr-Federated-Identity").iter().count(), + 1 + ); + assert_eq!( + headers["Nostr-Federated-Identity"], + "Bearer test.assertion.signature" + ); + assert_eq!(headers["Authorization"], proof); + assert!(!headers.contains_key(BB_SESSION_CREDENTIAL_HEADER)); + assert_eq!(actual_body, &body); +} + +#[tokio::test] +async fn employee_manual_refresh_waits_for_old_issuance_then_requires_a_fresh_receipt() { + let fixture = Fixture::new(true, false).await; + let old = fixture.evidence(); + tokio::pin!(old); + tokio::select! { _ = fixture.stub.entered.notified() => {}, _ = &mut old => panic!("must wait") } + let keys = fixture.state.signing_keys().unwrap(); + let refresh = + fixture + .session + .evidence_with_refresh(&fixture.state, &keys, &fixture.relay, true); + tokio::pin!(refresh); + fixture.stub.release.notify_one(); + old.await.unwrap(); + tokio::select! { _ = fixture.stub.entered.notified() => {}, _ = &mut refresh => panic!("manual refresh reused a pre-retry receipt") } + assert_eq!(fixture.stub.captured.lock().unwrap().len(), 2); + fixture.stub.release.notify_one(); + refresh.await.unwrap(); +} + +#[tokio::test] +async fn employee_http_response_cannot_reflect_assertion_through_errors_or_success_json() { + for success in [true, false] { + let fixture = Fixture::new(false, false).await; + let evidence = fixture.evidence().await.unwrap(); + *fixture.stub.response.lock().unwrap() = serde_json::json!({"message":"test.assertion.signature","nested":{"value":"test.assertion.signature"}}); + *fixture.stub.status.lock().unwrap() = if success { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + let base = fixture + .session + .request_context(&fixture.state) + .unwrap() + .unwrap() + .1; + let url = format!( + "{}/query", + Url::parse(base).unwrap().origin().ascii_serialization() + ); + let (client, request) = fixture.state.media_fetch_client.post(&url).build_split(); + let response = send_authorized_request(client, request.unwrap(), evidence) + .await + .unwrap(); + let error = if success { + crate::relay::parse_json_response::(response) + .await + .unwrap_err() + } else { + crate::relay::relay_error_message(response).await + }; + assert!(!error.contains("test.assertion.signature")); + } +} + +#[tokio::test] +async fn employee_response_body_is_canceled_on_logout_or_assertion_expiry() { + for expired in [true, false] { + let cancel = CancellationToken::new(); + let lifetime = if expired { + chrono::Utc::now().timestamp() - 1 + } else { + chrono::Utc::now().timestamp() + 60 + }; + let scope = ProtectedAssertion( + "test.assertion.signature".to_owned(), + cancel.clone(), + lifetime, + ); + if !expired { + cancel.cancel(); + } + let result = tokio::time::timeout( + Duration::from_secs(1), + guard_employee_response(Some(scope), std::future::pending::<()>()), + ) + .await + .unwrap(); + assert!(result.is_err()); + } +} + +#[tokio::test] +async fn ordinary_media_remains_available_while_identity_is_in_recovery() { + let fixture = Fixture::new(false, false).await; + fixture + .state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + let base = fixture.session.0.lock().unwrap().as_ref().unwrap().api_base; + let url = format!( + "{}/query", + Url::parse(base).unwrap().origin().ascii_serialization() + ); + let response = send_employee_media_request( + &fixture.state, + &url, + fixture.state.media_fetch_client.post(&url), + ) + .await + .unwrap(); + assert!(response.status().is_success()); + assert!(fixture.stub.captured.lock().unwrap()[0] + .0 + .get("Nostr-Federated-Identity") + .is_none()); +} + +#[tokio::test] +async fn employee_transfer_proofs_keep_blossom_kind_and_streaming_body() { + use nostr::JsonUtil; + for streaming in [true, false] { + let keys = nostr::Keys::generate(); + let proof = crate::commands::media::sign_blossom_upload_auth( + &keys, + &"a".repeat(64), + 300, + "https://employee.example", + ) + .unwrap(); + let auth = format!( + "Nostr {}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(proof.as_json()) + ); + let body = if streaming { + reqwest::Body::wrap_stream(futures_util::stream::iter([Ok::<_, std::io::Error>( + bytes::Bytes::from_static(b"media"), + )])) + } else { + reqwest::Body::from("media") + }; + let mut request = reqwest::Client::new() + .put("https://employee.example/upload") + .header("Authorization", &auth) + .body(body) + .build() + .unwrap(); + refresh_nip98_request(&mut request, &keys).unwrap(); + assert_eq!(request.headers()["Authorization"], auth); + assert_eq!(request.body().unwrap().as_bytes().is_none(), streaming); + } +} + +#[tokio::test] +async fn ended_employee_sessions_still_require_a_new_browser_login() { + let fixture = Fixture::new(false, false).await; + *fixture.stub.status.lock().unwrap() = StatusCode::UNAUTHORIZED; + *fixture.stub.response.lock().unwrap() = serde_json::json!({"error":"session_expired"}); + assert!(fixture.evidence().await.is_err()); + assert!(fixture.session.0.lock().unwrap().is_none()); + assert!(!fixture.session.employee_can_retry(&fixture.state)); +} + +#[test] +fn queued_employee_request_refreshes_only_the_expired_nip98_proof() { + use nostr::JsonUtil; + let keys = nostr::Keys::generate(); + let url = "https://employee.example/query"; + let body = b"[]"; + let old = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags([ + nostr::Tag::parse(["u", url]).unwrap(), + nostr::Tag::parse(["method", "POST"]).unwrap(), + nostr::Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from_secs(1)) + .sign_with_keys(&keys) + .unwrap(); + let mut request = reqwest::Client::new() + .post(url) + .header( + "Authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(old.as_json()) + ), + ) + .body(body.to_vec()) + .build() + .unwrap(); + refresh_nip98_request(&mut request, &keys).unwrap(); + let encoded = request.headers()["Authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event: nostr::Event = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(), + ) + .unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind, nostr::Kind::Custom(27235)); + assert_eq!(event.pubkey, keys.public_key()); + assert!(event.created_at.as_secs() > 1); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice().first().is_some_and(|value| value == "nonce"))); + for tag in old.tags.iter() { + assert!(event.tags.iter().any(|current| current == tag)); + } + assert_eq!(request.body().unwrap().as_bytes().unwrap(), body); +} + +#[tokio::test] +async fn transient_assertion_refresh_failure_can_retry_without_another_login() { + let fixture = Fixture::new(false, false).await; + fixture.evidence().await.unwrap(); + assert!(!fixture.session.employee_can_retry(&fixture.state)); + fixture.session.invalidate_employee_assertion().unwrap(); + *fixture.stub.status.lock().unwrap() = StatusCode::SERVICE_UNAVAILABLE; + assert!(fixture.evidence().await.is_err()); + assert!(fixture.session.employee_expiry(&fixture.state).is_none()); + assert!(fixture.session.employee_can_retry(&fixture.state)); + assert!(fixture + .session + .request_context(&fixture.state) + .unwrap() + .is_some()); + *fixture.stub.status.lock().unwrap() = StatusCode::OK; + let keys = fixture.state.signing_keys().unwrap(); + fixture + .session + .evidence_with_refresh(&fixture.state, &keys, &fixture.relay, true) + .await + .unwrap(); + assert!(fixture.session.employee_expiry(&fixture.state).is_some()); + assert!(!fixture.session.employee_can_retry(&fixture.state)); +} diff --git a/desktop/src-tauri/src/builderlab/employee_response.rs b/desktop/src-tauri/src/builderlab/employee_response.rs new file mode 100644 index 00000000000..70afcb901dc --- /dev/null +++ b/desktop/src-tauri/src/builderlab/employee_response.rs @@ -0,0 +1,196 @@ +//! Assertion reflection and lifetime boundary shared by JSON and streaming media. +use super::*; +use futures_util::{StreamExt, TryStreamExt}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub(crate) struct ProtectedAssertion( + pub(crate) String, + pub(crate) CancellationToken, + pub(crate) i64, +); + +pub(crate) async fn employee_response_end(scope: Option) { + if let Some(scope) = scope { + let lifetime = Duration::from_secs( + scope + .2 + .saturating_sub(chrono::Utc::now().timestamp()) + .max(0) as u64, + ); + tokio::select! { _ = scope.1.cancelled() => {}, _ = tokio::time::sleep(lifetime) => {} } + } else { + std::future::pending::<()>().await; + } +} + +pub(crate) async fn guard_employee_response( + scope: Option, + future: impl std::future::Future, +) -> Result { + tokio::select! { + biased; + _ = employee_response_end(scope) => Err("Employee relay authorization changed".to_owned()), + result = future => Ok(result), + } +} + +pub(super) fn filter_response(response: reqwest::Response) -> Result { + use reqwest::ResponseBuilderExt; + let scope = response + .extensions() + .get::() + .cloned() + .ok_or("Missing employee response scope")?; + if response.headers().iter().any(|(_, value)| { + value + .as_bytes() + .windows(scope.0.len()) + .any(|part| part == scope.0.as_bytes()) + }) { + return Err("Invalid employee relay response".to_owned()); + } + let mut builder = tauri::http::Response::builder() + .status(response.status()) + .version(response.version()) + .url(response.url().clone()); + *builder.headers_mut().ok_or("Invalid relay headers")? = response.headers().clone(); + builder + .extensions_mut() + .ok_or("Invalid relay metadata")? + .extend(response.extensions().clone()); + let stream = filter_stream( + response.bytes_stream().map_err(std::io::Error::other), + scope, + ); + let body = reqwest::Body::wrap_stream(stream); + let response = builder + .body(body) + .map_err(|_| "Invalid employee relay response")?; + Ok(response.into()) +} + +fn filter_stream( + stream: S, + scope: ProtectedAssertion, +) -> impl futures_util::Stream> + Send +where + S: futures_util::Stream> + Send + Unpin, +{ + // Retain one possible token prefix across chunk boundaries. Never release + // a complete reflected credential, even when the relay splits its bytes. + futures_util::stream::try_unfold( + (stream, Vec::::new(), scope, false), + |(mut stream, mut pending, scope, mut done)| async move { + loop { + if done { + return if pending.is_empty() { + Ok(None) + } else { + Ok(Some(( + bytes::Bytes::from(pending), + (stream, Vec::new(), scope, true), + ))) + }; + } + match guard_employee_response(Some(scope.clone()), stream.next()) + .await + .map_err(std::io::Error::other)? + { + Some(chunk) => { + pending.extend_from_slice(&chunk?); + if pending + .windows(scope.0.len()) + .any(|part| part == scope.0.as_bytes()) + { + return Err(std::io::Error::other("Invalid employee relay response")); + } + let available = pending + .len() + .saturating_sub(scope.0.len().saturating_sub(1)); + if available > 0 { + let remaining = pending.split_off(available); + return Ok(Some(( + bytes::Bytes::from(pending), + (stream, remaining, scope, false), + ))); + } + } + None => done = true, + } + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn split_reflection_is_never_reassembled_outside_native() { + let scope = ProtectedAssertion( + "private.media.assertion".to_owned(), + CancellationToken::new(), + chrono::Utc::now().timestamp() + 60, + ); + let stream = futures_util::stream::iter([ + Ok(bytes::Bytes::from_static(b"safe-prefix-private.med")), + Ok(bytes::Bytes::from_static(b"ia.assertion-after")), + ]); + let stream = filter_stream(stream, scope); + tokio::pin!(stream); + let mut released = Vec::new(); + let mut failed = false; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => released.extend_from_slice(&chunk), + Err(_) => { + failed = true; + break; + } + } + } + assert!(failed); + assert!(!released + .windows(b"private.media.assertion".len()) + .any(|part| part == b"private.media.assertion")); + } + #[test] + fn reflected_headers_are_rejected_before_either_media_proxy_can_forward_them() { + for name in ["content-type", "etag", "content-range"] { + let mut response: reqwest::Response = tauri::http::Response::builder() + .header(name, "private.media.assertion") + .body("safe") + .unwrap() + .into(); + response.extensions_mut().insert(ProtectedAssertion( + "private.media.assertion".to_owned(), + CancellationToken::new(), + chrono::Utc::now().timestamp() + 60, + )); + assert!(filter_response(response).is_err()); + } + } + #[tokio::test] + async fn ordinary_binary_chunks_are_preserved_exactly() { + let scope = ProtectedAssertion( + "private.media.assertion".to_owned(), + CancellationToken::new(), + chrono::Utc::now().timestamp() + 60, + ); + let input = vec![7u8; 256 * 1024]; + let stream = futures_util::stream::iter( + input + .chunks(4096) + .map(|chunk| Ok(bytes::Bytes::copy_from_slice(chunk))), + ); + let output = filter_stream(stream, scope) + .try_fold(Vec::new(), |mut result, chunk| async move { + result.extend_from_slice(&chunk); + Ok(result) + }) + .await + .unwrap(); + assert_eq!(output, input); + } +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ad44f86f9e2..dadecb2eda7 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -475,6 +475,7 @@ pub(crate) fn commit_imported_identity( *active_keys = keys; state.set_identity_storage(storage); } + crate::builderlab::invalidate_changed_employee_context(state); // Clear both recovery flags — an import is valid in either lost or // keyring-locked state and resolves both. In the locked case the diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 47d76520b51..ac1556c4da4 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -273,7 +273,7 @@ pub(super) async fn fetch_blob_bytes_with_cap( req = req.header("authorization", auth); } - let request = req.send(); + let request = crate::builderlab::send_employee_media_request(state, url, req); let resp = if let Some(cancellation) = cancellation { tokio::select! { _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), @@ -281,8 +281,7 @@ pub(super) async fn fetch_blob_bytes_with_cap( } } else { request.await - } - .map_err(|e| classify_request_error(&e))?; + }?; if let Some(err) = redirect_refusal_error(resp.status()) { return Err(err); @@ -306,15 +305,19 @@ pub(super) async fn fetch_blob_bytes_with_cap( // Stream the response with a running byte count to enforce the size cap // even when Content-Length is missing or dishonest. let mut bytes = Vec::new(); + let scope = resp + .extensions() + .get::() + .cloned(); let mut stream = resp.bytes_stream(); loop { let next = if let Some(cancellation) = cancellation { tokio::select! { _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), - next = stream.next() => next, + next = crate::builderlab::guard_employee_response(scope.clone(), stream.next()) => next?, } } else { - stream.next().await + crate::builderlab::guard_employee_response(scope.clone(), stream.next()).await? }; let Some(chunk) = next else { break; diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 5ed3f786521..fda0c2a0185 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -6,7 +6,7 @@ use std::{ use tauri::Emitter; use tokio_util::sync::CancellationToken; -use crate::{app_state::AppState, relay::classify_request_error}; +use crate::app_state::AppState; #[derive(Default)] struct MediaUploadCancellations { @@ -84,7 +84,8 @@ pub(super) async fn send_upload_attempt( } = attempt; let req = state .http_client - .put(url) + .put(&url) + .timeout(std::time::Duration::from_secs(5 * 60)) .header("Authorization", auth_header) .header("Content-Type", mime) .header("X-SHA-256", sha256); @@ -109,8 +110,8 @@ pub(super) async fn send_upload_attempt( })); let request = req .header(reqwest::header::CONTENT_LENGTH, total) - .body(reqwest::Body::wrap_stream(stream)) - .send(); + .body(reqwest::Body::wrap_stream(stream)); + let request = crate::builderlab::send_employee_media_request(state, &url, request); if let Some(cancellation) = cancellation { tokio::select! { _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), @@ -120,7 +121,7 @@ pub(super) async fn send_upload_attempt( request.await } } else { - let request = req.body(body).send(); + let request = crate::builderlab::send_employee_media_request(state, &url, req.body(body)); if let Some(cancellation) = cancellation { tokio::select! { _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), @@ -130,7 +131,7 @@ pub(super) async fn send_upload_attempt( request.await } }; - response.map_err(|error| classify_request_error(&error)) + response } pub(super) fn emit_media_upload_phase( diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index f570d8e0b6b..71eb7075754 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -266,6 +266,8 @@ pub async fn apply_workspace( *keys_guard = keys; } + crate::builderlab::invalidate_changed_employee_context(&state); + // Keep the backend-side reconcile guard aligned with the frontend // experiment before launch-time restore can spawn any agents. Missing // means the stable behavior: desktop remains authoritative. diff --git a/desktop/src-tauri/src/employee_api.rs b/desktop/src-tauri/src/employee_api.rs new file mode 100644 index 00000000000..686d8a083ff --- /dev/null +++ b/desktop/src-tauri/src/employee_api.rs @@ -0,0 +1,155 @@ +//! Explicit staging pilot routing; the identity origin and signed URLs stay fixed. + +use reqwest::{header::HeaderValue, Method, Request, RequestBuilder}; + +const TARGET_ENV: &str = "BUZZ_EMPLOYEE_NATIVE_PLAYPEN_TARGET"; + +pub(crate) fn request(builder: RequestBuilder) -> Result { + let target = std::env::var(TARGET_ENV).ok(); + request_with_target(builder, target.as_deref()) +} + +fn request_with_target(builder: RequestBuilder, target: Option<&str>) -> Result { + let mut request = builder + .build() + .map_err(|_| "Could not prepare Square identity request")?; + // A request's ambient trace context must not choose its identity backend. + request.headers_mut().remove("baggage"); + let Some(target) = target else { + return Ok(request); + }; + let valid_target = !target.is_empty() + && target.len() <= 63 + && target.as_bytes()[0].is_ascii_lowercase() + && target + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + && target + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + let action = match request.url().path() { + "/api/goose/v1/auth/merchant-team-member/native-handoff/redeem" => { + request.method() == Method::POST + } + "/api/goose/v1/auth/me" => request.method() == Method::GET, + "/api/goose/v1/buzz/identity/assertions" => request.method() == Method::POST, + _ => false, + }; + // reqwest removes URL user info and turns it into Basic authentication. + // These fixed identity actions never accept that alternate auth channel. + let valid_authorization = match request.headers().get("authorization") { + None => true, + Some(value) => { + request.url().path() == "/api/goose/v1/buzz/identity/assertions" + && value + .to_str() + .is_ok_and(|value| value.starts_with("Nostr ")) + } + }; + let expected = format!( + "https://square.builderlabstaging.xyz{}", + request.url().path() + ); + if !valid_target || !action || !valid_authorization || request.url().as_str() != expected { + return Err("Square pilot routing is invalid".to_owned()); + } + let value = HeaderValue::from_str(&format!("kgoose-builderbot-playpen={target}")) + .map_err(|_| "Square pilot routing is invalid")?; + request.headers_mut().insert("baggage", value); + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pilot_routing_is_exact_overwrites_ambient_and_preserves_signed_request() { + let client = reqwest::Client::new(); + for (method, path) in [ + ( + Method::POST, + "/v1/auth/merchant-team-member/native-handoff/redeem", + ), + (Method::GET, "/v1/auth/me"), + (Method::POST, "/v1/buzz/identity/assertions"), + ] { + let url = format!("{}{path}", crate::employee_login::API_BASE); + let mut builder = client + .request(method.clone(), &url) + .header("baggage", "kgoose-builderbot-playpen=old") + .header("baggage", "untrusted=value") + .header("X-BB-Session-Credential", "synthetic-credential") + .body("exact-signed-body"); + let is_assertion = path == "/v1/buzz/identity/assertions"; + if is_assertion { + builder = builder.header("Authorization", "Nostr synthetic-proof"); + } + let request = request_with_target(builder, Some("employee-pilot-621")).unwrap(); + assert_eq!(request.url().as_str(), url); + assert_eq!(request.method(), method); + assert_eq!( + request.body().unwrap().as_bytes(), + Some(b"exact-signed-body".as_slice()) + ); + if is_assertion { + assert_eq!(request.headers()["Authorization"], "Nostr synthetic-proof"); + } else { + assert!(!request.headers().contains_key("authorization")); + } + assert_eq!( + request.headers()["X-BB-Session-Credential"], + "synthetic-credential" + ); + assert_eq!(request.headers().get_all("baggage").iter().count(), 1); + assert_eq!( + request.headers()["baggage"], + "kgoose-builderbot-playpen=employee-pilot-621" + ); + } + let request = request_with_target( + client + .get(format!("{}/v1/auth/me", crate::employee_login::API_BASE)) + .header("baggage", "kgoose-builderbot-playpen=ambient"), + None, + ) + .unwrap(); + assert!(!request.headers().contains_key("baggage")); + } + + #[test] + fn routing_refuses_other_origins_actions_methods_and_header_injection() { + let client = reqwest::Client::new(); + for target in [ + "", + "EMP621", + "-emp621", + "emp621-", + "emp621,other=value", + "emp621\r\nx: y", + "emp_621", + &"e".repeat(64), + ] { + assert!(request_with_target( + client.get(format!("{}/v1/auth/me", crate::employee_login::API_BASE)), + Some(target) + ) + .is_err()); + } + for (method,url) in [ + (Method::GET, "https://app.builderlab.xyz/api/goose/v1/auth/me"), + (Method::GET, "https://login.dev.builderlab.xyz/oauth/token"), + (Method::GET, "http://square.builderlabstaging.xyz/api/goose/v1/auth/me"), + (Method::GET, "https://square.builderlabstaging.xyz:444/api/goose/v1/auth/me"), + (Method::GET, "https://user@square.builderlabstaging.xyz/api/goose/v1/auth/me"), + (Method::GET, "https://square.builderlabstaging.xyz/api/goose/v1/auth/me?target=other"), + (Method::GET, "https://square.builderlabstaging.xyz/api/goose/v1/auth/me#other"), + (Method::POST, "https://square.builderlabstaging.xyz/api/goose/v1/auth/me"), + (Method::GET, "https://square.builderlabstaging.xyz/api/goose/v1/buzz/identity/assertions"), + (Method::POST, "https://square.builderlabstaging.xyz/api/goose/v1/auth/merchant-team-member/exchange"), + ] { + assert!(request_with_target(client.request(method,url), Some("emp621")).is_err(), "{url}"); + } + } +} diff --git a/desktop/src-tauri/src/employee_login.rs b/desktop/src-tauri/src/employee_login.rs new file mode 100644 index 00000000000..a81d76b45e7 --- /dev/null +++ b/desktop/src-tauri/src/employee_login.rs @@ -0,0 +1,481 @@ +//! Caller-bound Square employee login into the existing native BB session. +//! Only a verifier-bound one-use code crosses the browser callback boundary. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::get, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use serde::{de::DeserializeOwned, Deserialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; +use tauri_plugin_opener::OpenerExt; +use tokio::{net::TcpListener, sync::oneshot}; +use url::Url; + +use crate::builderlab::{ + api_url_for_base, authenticated_user, BuilderlabAuthInfo, BuilderlabLogin, BuilderlabSession, +}; + +pub(crate) const API_BASE: &str = "https://square.builderlabstaging.xyz/api/goose"; +const SQUARE_SIGN_IN: &str = "https://app.squareupstaging.com/dashboard/managerbot/buzz-sign-in"; +const REDEEM_PATH: &str = "/v1/auth/merchant-team-member/native-handoff/redeem"; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const MAX_RESPONSE: usize = 64 * 1024; + +/// The integration is disabled unless the operator explicitly opts in. +#[tauri::command] +pub(crate) fn employee_sign_in_available() -> bool { + configured_relay().ok().flatten().is_some() +} + +/// Deployment-owned exact relay opt-in. A selected community never selects the identity service. +pub(crate) fn configured_relay() -> Result, String> { + if !std::env::var("BUZZ_EMPLOYEE_NATIVE_SIGN_IN").is_ok_and(|value| value == "1") { + return Ok(None); + } + let value = std::env::var("BUZZ_EMPLOYEE_NATIVE_RELAY_URL") + .map_err(|_| "Square relay is not configured")?; + let parsed = Url::parse(&value).map_err(|_| "Square relay is invalid")?; + let canonical = format!( + "wss://{}", + &parsed[url::Position::BeforeHost..url::Position::AfterPort] + ); + if parsed.scheme() != "wss" + || parsed.host_str().is_none() + || parsed.port() == Some(80) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || value != canonical + { + return Err("Square relay must be one canonical WSS origin".to_owned()); + } + Ok(Some(value)) +} + +fn random_parameter() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes) + .map_err(|_| "Could not create secure sign-in transaction".to_owned())?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn is_parameter(value: &str) -> bool { + value.len() == 43 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') +} + +struct CallbackState { + nonce: String, + state: String, + sender: Mutex>>, +} + +async fn callback( + Path(nonce): Path, + Query(params): Query>, + State(state): State>, +) -> Response { + let valid = params.len() == 2 + && params.iter().filter(|(key, _)| key == "code").count() == 1 + && params.iter().filter(|(key, _)| key == "state").count() == 1; + let params: HashMap<_, _> = params.into_iter().collect(); + if !valid + || nonce != state.nonce + || params.get("state") != Some(&state.state) + || !params.get("code").is_some_and(|code| is_parameter(code)) + { + return (StatusCode::BAD_REQUEST, "Invalid sign-in callback").into_response(); + } + let Ok(mut sender) = state.sender.lock() else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let Some(sender) = sender.take() else { + return (StatusCode::CONFLICT, "Sign-in callback already received").into_response(); + }; + let Some(code) = params.get("code") else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let _ = sender.send(code.clone()); + ([ ("Cache-Control", "no-store"), ("Referrer-Policy", "no-referrer") ], Html("Return to Buzz

Return to Buzz

Buzz will finish checking your Square sign-in. You can close this window.

")).into_response() +} + +fn login_url(return_to: &str, state: &str, verifier: &str) -> Result { + let mut url = Url::parse(SQUARE_SIGN_IN).map_err(|_| "Invalid Square sign-in configuration")?; + url.query_pairs_mut() + .append_pair("return_to", return_to) + .append_pair("state", state) + .append_pair( + "code_challenge", + &URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())), + ) + .append_pair("code_challenge_method", "S256"); + Ok(url) +} + +struct StopListener(tokio::task::JoinHandle<()>); +impl Drop for StopListener { + fn drop(&mut self) { + self.0.abort(); + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RedeemedSession { + session_credential: String, + expires_at: String, + subject_id: String, + org: String, + workspace_identifier: String, +} + +async fn read_response(mut response: reqwest::Response) -> Result { + if !response.status().is_success() { + return Err(format!( + "Square sign-in refused (HTTP {})", + response.status() + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "Could not read Square sign-in response")? + { + if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE { + return Err("Square sign-in response is too large".to_owned()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| "Invalid Square sign-in response".to_owned()) +} + +fn verify_session( + receipt: &RedeemedSession, + subject: Option<&str>, + expiry: &str, +) -> Result<(), String> { + let receipt_expiry = DateTime::parse_from_rfc3339(&receipt.expires_at) + .map_err(|_| "Invalid Square session expiry")?; + let checked_expiry = + DateTime::parse_from_rfc3339(expiry).map_err(|_| "Invalid Square session expiry")?; + if receipt.session_credential.is_empty() + || receipt.subject_id.is_empty() + || receipt.org.is_empty() + || receipt.workspace_identifier.is_empty() + || subject != Some(receipt.subject_id.as_str()) + || receipt_expiry != checked_expiry + || receipt_expiry <= Utc::now() + { + return Err("Square session binding did not match".to_owned()); + } + Ok(()) +} + +/// Opens Square's employee session flow and commits only the exact verified CTE session. +#[tauri::command] +pub(crate) async fn start_employee_login( + app: tauri::AppHandle, + app_state: tauri::State<'_, crate::app_state::AppState>, + session: tauri::State<'_, BuilderlabSession>, + login: tauri::State<'_, BuilderlabLogin>, +) -> Result { + if !employee_sign_in_available() { + return Err("Square employee sign-in is not enabled".to_owned()); + } + let relay = configured_relay()?.ok_or("Square sign-in is not configured")?; + if crate::relay::relay_ws_url_with_override(&app_state) != relay { + return Err("Select the configured Square community before signing in".to_owned()); + } + let workspace_generation = app_state + .workspace_apply_generation + .load(std::sync::atomic::Ordering::Acquire); + let pubkey = app_state.signing_keys()?.public_key().to_hex(); + let verifier = random_parameter()?; + let state = random_parameter()?; + let nonce = random_parameter()?; + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|_| "Could not start local sign-in listener")?; + let port = listener + .local_addr() + .map_err(|_| "Could not read local sign-in listener")? + .port(); + let return_to = format!("http://127.0.0.1:{port}/callback/{nonce}"); + let (sender, receiver) = oneshot::channel(); + let router = Router::new() + .route("/callback/{nonce}", get(callback)) + .with_state(Arc::new(CallbackState { + nonce, + state: state.clone(), + sender: Mutex::new(Some(sender)), + })); + let server = StopListener(tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + })); + let (login_id, mut cancel) = login.begin_employee_login()?; + let url = login_url(&return_to, &state, &verifier)?; + app.opener() + .open_url(url.as_str(), None::<&str>) + .map_err(|_| "Could not open Square sign-in")?; + let code = tokio::select! { + result = tokio::time::timeout(LOGIN_TIMEOUT,receiver) => result.map_err(|_| "Square sign-in timed out")?.map_err(|_| "Square sign-in listener stopped")?, + _ = &mut cancel => return Err("Square sign-in canceled".to_owned()), + }; + drop(server); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| "Could not configure Square sign-in")?; + let request = crate::employee_api::request(client.post(api_url_for_base(API_BASE, REDEEM_PATH)?).json(&serde_json::json!({"code":code,"code_verifier":verifier,"return_to":return_to,"state":state})))?; + let response = client + .execute(request) + .await + .map_err(|_| "Could not exchange Square sign-in code")?; + let receipt: RedeemedSession = read_response(response).await?; + let me = authenticated_user(&client, &receipt.session_credential, API_BASE).await?; + verify_session(&receipt, me.subject.as_deref(), &me.expires_at)?; + let _identity_guard = app_state + .identity_mutation + .lock() + .map_err(|_| "Buzz identity changed")?; + if app_state.signing_keys()?.public_key().to_hex() != pubkey { + return Err("Buzz identity changed. Sign in again.".to_owned()); + } + if app_state + .workspace_apply_generation + .load(std::sync::atomic::Ordering::Acquire) + != workspace_generation + || crate::relay::relay_ws_url_with_override(&app_state) != relay + { + return Err("Community changed. Sign in again.".to_owned()); + } + let expires_at = DateTime::parse_from_rfc3339(&receipt.expires_at) + .map_err(|_| "Invalid Square session expiry")? + .timestamp(); + session.finish_employee_login( + &login, + login_id, + receipt.session_credential.clone(), + API_BASE, + crate::builderlab::EmployeeBinding { + pubkey, + expires_at, + relay: relay.clone(), + }, + )?; + drop(_identity_guard); + let keys = app_state.signing_keys()?; + // The existing key-bound NIP-FI issuance atomically verifies and enrolls this own key. + let (result, canceled) = tokio::select! { + biased; + _ = &mut cancel => (Err("Square sign-in canceled".to_owned()), true), + result = crate::builderlab::employee_evidence(&app_state, &keys, &relay) => (result.map(|_| ()), false), + }; + if let Err(error) = result { + session.clear_failed_employee_login(&receipt.session_credential, canceled)?; + let _ = login.complete_employee_login(login_id); + crate::builderlab::notify_session_changed(&app_state); + return Err(error); + } + if let Err(error) = login.complete_employee_login(login_id) { + session.clear_if_credential(&receipt.session_credential)?; + crate::builderlab::notify_session_changed(&app_state); + return Err(error); + } + crate::builderlab::notify_session_changed(&app_state); + Ok(BuilderlabAuthInfo { + expires_at: me.expires_at, + email: None, + name: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn browser_url_contains_only_public_transaction_data() { + let verifier = "v".repeat(43); + let url = login_url( + "http://127.0.0.1:12345/callback/nonce", + &"s".repeat(43), + &verifier, + ) + .unwrap(); + assert_eq!(url.host_str(), Some("app.squareupstaging.com")); + assert!(!url.as_str().contains(&verifier)); + assert!(!url.as_str().contains("session_credential")); + let params: HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert_eq!( + params.get("code_challenge"), + Some(&URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))) + ); + } + #[tokio::test] + async fn callback_rejects_state_and_duplicate_parameters_without_consuming() { + let (sender, mut receiver) = oneshot::channel(); + let state = Arc::new(CallbackState { + nonce: "nonce".to_owned(), + state: "state".to_owned(), + sender: Mutex::new(Some(sender)), + }); + let code = "c".repeat(43); + for pairs in [ + vec![ + ("code".to_owned(), code.clone()), + ("state".to_owned(), "wrong".to_owned()), + ], + vec![ + ("code".to_owned(), code.clone()), + ("code".to_owned(), code.clone()), + ("state".to_owned(), "state".to_owned()), + ], + ] { + assert_eq!( + callback(Path("nonce".to_owned()), Query(pairs), State(state.clone())) + .await + .status(), + StatusCode::BAD_REQUEST + ); + assert!(receiver.try_recv().is_err()); + } + let pairs = vec![ + ("code".to_owned(), code.clone()), + ("state".to_owned(), "state".to_owned()), + ]; + assert_eq!( + callback( + Path("nonce".to_owned()), + Query(pairs.clone()), + State(state.clone()) + ) + .await + .status(), + StatusCode::OK + ); + assert_eq!(receiver.await.unwrap(), code); + assert_eq!( + callback(Path("nonce".to_owned()), Query(pairs), State(state)) + .await + .status(), + StatusCode::CONFLICT + ); + } + #[tokio::test] + async fn native_loopback_runs_through_real_http_query_extraction() { + let (sender, receiver) = oneshot::channel(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let state = Arc::new(CallbackState { + nonce: "nonce".to_owned(), + state: "state".to_owned(), + sender: Mutex::new(Some(sender)), + }); + let router = Router::new() + .route("/callback/{nonce}", get(callback)) + .with_state(state); + let server = StopListener(tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + })); + let response = reqwest::Client::new() + .get(format!( + "http://127.0.0.1:{port}/callback/nonce?code={}&state=state", + "c".repeat(43) + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(receiver.await.unwrap(), "c".repeat(43)); + drop(server); + } + #[test] + fn receipt_requires_same_subject_and_expiry_instant() { + let expiry = (Utc::now() + chrono::Duration::minutes(4)).to_rfc3339(); + let receipt = RedeemedSession { + session_credential: "secret".to_owned(), + expires_at: expiry.clone(), + subject_id: "person".to_owned(), + org: "org".to_owned(), + workspace_identifier: "workspace".to_owned(), + }; + assert!(verify_session(&receipt, Some("person"), &expiry).is_ok()); + assert!(verify_session(&receipt, Some("other"), &expiry).is_err()); + assert!(verify_session( + &receipt, + Some("person"), + &(Utc::now() + chrono::Duration::hours(8)).to_rfc3339() + ) + .is_err()); + } +} + +/// Public session state for the pre-connection employee login gate. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EmployeeSessionStatus { + required: bool, + authenticated: bool, + can_retry: bool, + expires_at: Option, +} + +#[tauri::command] +pub(crate) fn employee_session_status( + app_state: tauri::State<'_, crate::app_state::AppState>, + session: tauri::State<'_, BuilderlabSession>, +) -> Result { + let relay = crate::relay::relay_ws_url_with_override(&app_state); + if !crate::builderlab::requires_employee(&relay)? { + return Ok(EmployeeSessionStatus { + required: false, + authenticated: false, + can_retry: false, + expires_at: None, + }); + } + let expires_at = session.employee_expiry(&app_state); + Ok(EmployeeSessionStatus { + required: true, + authenticated: expires_at.is_some(), + can_retry: session.employee_can_retry(&app_state), + expires_at, + }) +} + +/// Explicitly rechecks the current employee's key and membership before reconnecting. +#[tauri::command] +pub(crate) async fn refresh_employee_relay_access( + app_state: tauri::State<'_, crate::app_state::AppState>, + session: tauri::State<'_, BuilderlabSession>, +) -> Result<(), String> { + let keys = app_state.signing_keys()?; + let relay = crate::relay::relay_ws_url_with_override(&app_state); + if !crate::builderlab::requires_employee(&relay)? { + return Err("Select the configured Square community".to_owned()); + } + session + .evidence_with_refresh(&app_state, &keys, &relay, true) + .await?; + crate::builderlab::notify_session_changed(&app_state); + Ok(()) +} diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 66c80f5d6c6..fbdfe683143 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -264,6 +264,11 @@ impl PeerSlot { } } +fn valid_control_roster(value: &serde_json::Value) -> bool { + !matches!(value["type"].as_str(), Some("joined" | "roster")) + || buzz_audio_client::connection::parse_peers(value).is_ok() +} + /// Drive the receive loop until cancelled or the WS closes. /// /// `ws_tx_for_pongs` is shared with the encode-side task and only used here to @@ -538,6 +543,7 @@ pub(crate) async fn run_playout_recv_loop( } Some(Ok(WsMsg::Text(text))) => { if let Ok(v) = serde_json::from_str::(&text) { + if !valid_control_roster(&v) { return; } match v["type"].as_str() { Some("joined") => { if let Some(peer_list) = v["peers"].as_array() { @@ -668,6 +674,19 @@ pub(crate) async fn run_playout_recv_loop( #[cfg(test)] mod tests { use super::*; + #[test] + fn employee_control_roster_rejects_reflected_assertions_before_speaker_events() { + for kind in ["joined", "roster"] { + let text = format!( + r#"{{"type":"{kind}","peers":[{{"pubkey":"private\u002eaudio\u002eassertion","peer_index":1}}]}}"# + ); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert!(!valid_control_roster(&value)); + assert!(valid_control_roster( + &serde_json::json!({"type":kind,"peers":[{"pubkey":"a".repeat(64),"peer_index":1}]}) + )); + } + } #[test] fn continuous_silence_does_not_extend_remote_floor_deadline() { diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 53c757b986c..e9580ba2421 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -73,21 +73,57 @@ async fn connect_authenticated_audio_socket( relay_url: &str, keys: &nostr::Keys, auth_tag_json: Option<&str>, + employee: Option<&crate::builderlab::EmployeeEvidence>, ) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), String> { use nostr::JsonUtil; let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); - let joined = buzz_audio_client::connection::connect( - &ws_url, + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut request = ws_url + .into_client_request() + .map_err(|_| "Invalid Huddle URL")?; + if let Some(employee) = employee { + request + .headers_mut() + .insert("Nostr-Federated-Identity", employee.header.clone()); + } + let connecting = buzz_audio_client::connection::connect( + request, parent_channel_id, None, |challenge| async move { + if let Some(employee) = employee { + let token = employee + .header + .to_str() + .map_err(|_| "Invalid employee evidence")? + .trim_start_matches("Bearer "); + if employee.cancel.is_cancelled() + || employee.expires_at <= chrono::Utc::now().timestamp() + || challenge.contains(token) + { + return Err("Employee Huddle authorization changed".to_owned()); + } + } let event = build_audio_auth_event(keys, relay_url, &challenge, auth_tag_json)?; serde_json::from_str(&event.as_json()) .map_err(|e| format!("failed to serialize auth event: {e}")) }, - ) - .await?; + ); + let joined = if let Some(employee) = employee { + tokio::select! { + biased; + _ = employee.cancel.cancelled() => return Err("Square sign-in changed".to_owned()), + result = connecting => result.map_err(|_| "Employee Huddle connection refused".to_owned())?, + } + } else { + connecting.await? + }; + if employee.is_some_and(|value| { + value.cancel.is_cancelled() || value.expires_at <= chrono::Utc::now().timestamp() + }) { + return Err("Employee Huddle authorization changed".to_owned()); + } let (tx, rx) = joined.socket.split(); let peers = joined .peers @@ -107,7 +143,7 @@ pub(crate) async fn connect_audio_relay( state: &AppState, ) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { let relay_url = crate::relay::relay_ws_url_with_override(state); - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + let keys = state.signing_keys()?; // TTS interrupt flags — recv task cancels TTS when remote humans speak. let ( @@ -131,12 +167,41 @@ pub(crate) async fn connect_audio_relay( let app_handle = state.app_handle.lock().ok().and_then(|g| g.clone()); - let (ws_tx, ws_rx, _peer_index, initial_peers) = - connect_authenticated_audio_socket(channel_id, parent_channel_id, &relay_url, &keys, None) - .await?; + let employee = crate::builderlab::employee_evidence(state, &keys, &relay_url).await?; + let (ws_tx, ws_rx, _peer_index, initial_peers) = connect_authenticated_audio_socket( + channel_id, + parent_channel_id, + &relay_url, + &keys, + None, + employee.as_ref(), + ) + .await?; let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); + if let Some(employee) = employee { + let cancel = cancel.clone(); + let app_handle = app_handle.clone(); + tokio::spawn(async move { + let lifetime = std::time::Duration::from_secs( + employee + .expires_at + .saturating_sub(chrono::Utc::now().timestamp()) + .max(0) as u64, + ); + tokio::select! { + _ = employee.cancel.cancelled() => {}, + _ = tokio::time::sleep(lifetime) => {}, + _ = cancel.cancelled() => return, + } + cancel.cancel(); + if let Some(handle) = app_handle { + use tauri::Emitter; + let _ = handle.emit("huddle-audio-disconnected", ()); + } + }); + } let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); let output_device_name = state .huddle_audio @@ -251,6 +316,7 @@ pub(crate) async fn connect_tts_audio_publisher( &relay_url, keys, auth_tag_json, + None, ) .await?; @@ -518,6 +584,108 @@ pub(crate) async fn count_human_members( mod tests { use super::*; + #[tokio::test] + async fn employee_huddle_upgrade_uses_native_assertion_and_same_own_key_proof() { + use nostr::JsonUtil; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("ws://{}", listener.local_addr().unwrap()); + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + #[allow(clippy::result_large_err)] + let check = |request: &tokio_tungstenite::tungstenite::handshake::server::Request, + response| { + assert_eq!( + request.headers()["Nostr-Federated-Identity"], + "Bearer private.huddle.assertion" + ); + assert!(request.uri().query().is_none()); + Ok(response) + }; + let mut ws = tokio_tungstenite::accept_hdr_async(stream, check) + .await + .unwrap(); + ws.send(WsMsg::Text( + serde_json::json!({"type":"challenge","challenge":"public-nonce"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let proof: serde_json::Value = + serde_json::from_str(ws.next().await.unwrap().unwrap().to_text().unwrap()).unwrap(); + assert!(!proof.to_string().contains("private.huddle.assertion")); + let event = nostr::Event::from_json(proof["event"].to_string()).unwrap(); + event.verify().unwrap(); + assert_eq!(event.pubkey, pubkey); + ws.send(WsMsg::Text( + serde_json::json!({"type":"joined","peer_index":0,"peers":[]}) + .to_string() + .into(), + )) + .await + .unwrap(); + }); + let employee = crate::builderlab::EmployeeEvidence { + header: "Bearer private.huddle.assertion".parse().unwrap(), + cancel: CancellationToken::new(), + expires_at: chrono::Utc::now().timestamp() + 60, + }; + let result = connect_authenticated_audio_socket( + "channel", + None, + &relay, + &keys, + None, + Some(&employee), + ) + .await; + assert!(result.is_ok()); + server.await.unwrap(); + } + + #[tokio::test] + async fn canceled_employee_huddle_does_not_sign_a_delayed_challenge() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("ws://{}", listener.local_addr().unwrap()); + let keys = nostr::Keys::generate(); + let (entered, ready) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + entered.send(()).unwrap(); + // Cancellation must drop the socket while the relay hasn't offered a challenge. + while let Some(message) = ws.next().await { + assert!( + !matches!(message, Ok(WsMsg::Text(_))), + "obsolete employee signed AUTH" + ); + } + }); + let employee = crate::builderlab::EmployeeEvidence { + header: "Bearer private.huddle.assertion".parse().unwrap(), + cancel: CancellationToken::new(), + expires_at: chrono::Utc::now().timestamp() + 60, + }; + let connecting = connect_authenticated_audio_socket( + "channel", + None, + &relay, + &keys, + None, + Some(&employee), + ); + tokio::pin!(connecting); + tokio::select! { _ = ready => {}, _ = &mut connecting => panic!("must await challenge") } + employee.cancel.cancel(); + assert!(connecting.await.unwrap_err().contains("changed")); + tokio::time::timeout(std::time::Duration::from_secs(1), server) + .await + .unwrap() + .unwrap(); + } + #[test] fn tts_upsampling_doubles_rate_with_linear_midpoints() { assert_eq!( diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4497eb78e56..1470feb83a4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ mod channel_head_cache; mod commands; mod deep_link; mod egress_guard; +mod employee_api; +mod employee_login; mod event_sync; mod events; #[cfg_attr(not(test), allow(dead_code))] @@ -335,6 +337,9 @@ pub fn run() { if let Ok(mut guard) = state.app_handle.lock() { *guard = Some(app_handle.clone()); } + app_handle + .state::() + .set_app_handle(app_handle.clone())?; let (tts_settings, tts_settings_load_error) = huddle::tts_settings::load_for_app(&app_handle); @@ -544,6 +549,10 @@ pub fn run() { take_pending_entity_deep_link, acknowledge_pending_entity_deep_link, start_builderlab_login, + employee_login::employee_sign_in_available, + employee_login::start_employee_login, + employee_login::employee_session_status, + employee_login::refresh_employee_relay_access, cancel_builderlab_login, get_builderlab_auth, clear_builderlab_auth, diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 79128eca0f8..69a46e925ed 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -6,7 +6,7 @@ use axum::{ routing::get, Router, }; -use futures_util::TryStreamExt; +use futures_util::{StreamExt, TryStreamExt}; use tauri::{http, Manager}; use tokio::net::TcpListener; @@ -69,12 +69,15 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> } } - let resp = match upstream.send().await { - Ok(r) => r, - Err(_) => { - return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response(); - } - }; + let resp = + match crate::builderlab::send_employee_media_request(&app_state, &upstream_url, upstream) + .await + { + Ok(r) => r, + Err(_) => { + return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response(); + } + }; // Re-mint and retry once on an auth failure (401 missing_evidence or 403 // evidence_rejected). Under NIP-FI strict mode a read token expires after @@ -95,7 +98,9 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> retry = retry.header("range", v); } } - match retry.send().await { + match crate::builderlab::send_employee_media_request(&app_state, &upstream_url, retry) + .await + { Ok(r) => r, Err(_) => { return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response(); @@ -150,7 +155,14 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> } // Stream the body — no buffering. - let stream = resp.bytes_stream().map_err(std::io::Error::other); + let scope = resp + .extensions() + .get::() + .cloned(); + let stream = resp + .bytes_stream() + .take_until(crate::builderlab::employee_response_end(scope)) + .map_err(std::io::Error::other); let body = Body::from_stream(stream); (status, headers, body).into_response() @@ -229,7 +241,8 @@ pub async fn handle_buzz_media( } } - let result = upstream.send().await; + let result = + crate::builderlab::send_employee_media_request(&state, &upstream_url, upstream).await; // Re-mint and retry once on auth failure (401/403) for range requests. // Under NIP-FI strict mode a read token expires after 60s, exhausting @@ -251,7 +264,7 @@ pub async fn handle_buzz_media( retry = retry.header("range", v); } } - retry.send().await + crate::builderlab::send_employee_media_request(&state, &upstream_url, retry).await } else { Ok(resp) } @@ -323,7 +336,15 @@ pub async fn handle_buzz_media( } } - match resp.bytes().await { + let scope = resp + .extensions() + .get::() + .cloned(); + let body = match crate::builderlab::guard_employee_response(scope, resp.bytes()).await { + Ok(result) => result, + Err(_) => return error_response(401, "Square authorization changed"), + }; + match body { Ok(bytes) => { let mut builder = http::Response::builder() .status(status) diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs index fd97f4b9b72..121721cf302 100644 --- a/desktop/src-tauri/src/native_relay_client.rs +++ b/desktop/src-tauri/src/native_relay_client.rs @@ -84,6 +84,7 @@ pub(crate) struct MatchedEvent { #[derive(Default)] pub(crate) struct NativeRelayClient { current: Mutex>, + app_handle: std::sync::Mutex>, } struct ManagedSession { @@ -132,6 +133,19 @@ impl Drop for SessionLease { } impl NativeRelayClient { + pub(crate) fn set_app_handle(&self, handle: tauri::AppHandle) -> Result<(), String> { + *self + .app_handle + .lock() + .map_err(|_| "Native relay context unavailable")? = Some(handle); + Ok(()) + } + fn app_handle(&self) -> Option { + self.app_handle + .lock() + .ok() + .and_then(|handle| handle.clone()) + } /// Installs the session for `scope`, shutting down whatever scope held the /// slot. Destructive on entry, so every caller must already hold proof it /// is the current owner — today that is @@ -139,13 +153,16 @@ impl NativeRelayClient { async fn ensure_session(&self, relay_url: String, keys: Keys) -> Arc { let scope = (relay_url.clone(), keys.public_key().to_hex()); let mut current = self.current.lock().await; - if let Some(managed) = current.as_ref().filter(|managed| managed.scope == scope) { + if let Some(managed) = current + .as_ref() + .filter(|managed| managed.scope == scope && !managed.session.cancel.is_cancelled()) + { return Arc::clone(&managed.session); } if let Some(previous) = current.take() { previous.session.shutdown(); } - let session = start_managed(relay_url, keys, None); + let session = start_managed(relay_url, keys, None, self.app_handle()); *current = Some(ManagedSession { scope, session: Arc::clone(&session), @@ -179,6 +196,12 @@ impl NativeRelayClient { pub(crate) async fn session(&self, relay_url: String, keys: Keys) -> SessionLease { let scope = (relay_url.clone(), keys.public_key().to_hex()); let mut current = self.current.lock().await; + if current + .as_ref() + .is_some_and(|managed| managed.session.cancel.is_cancelled()) + { + *current = None; + } if let Some(managed) = current.as_ref() { return if managed.scope == scope { SessionLease { @@ -187,12 +210,12 @@ impl NativeRelayClient { } } else { SessionLease { - session: start_managed(relay_url, keys, None), + session: start_managed(relay_url, keys, None, self.app_handle()), private: true, } }; } - let session = start_managed(relay_url, keys, None); + let session = start_managed(relay_url, keys, None, self.app_handle()); *current = Some(ManagedSession { scope, session: Arc::clone(&session), @@ -439,12 +462,17 @@ pub(crate) async fn start( keys: Keys, auth_tag: Option, ) -> (Arc, mpsc::Receiver) { - let session = start_managed(relay_url, keys, auth_tag); + let session = start_managed(relay_url, keys, auth_tag, None); let events = session.attach_archive().await; (session, events) } -fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> Arc { +fn start_managed( + relay_url: String, + keys: Keys, + auth_tag: Option, + app_handle: Option, +) -> Arc { let (wake, wake_rx) = mpsc::channel(1); let session = Arc::new(RelaySession { state: Arc::new(Mutex::new(SessionState::default())), @@ -460,6 +488,7 @@ fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> auth_tag, Arc::clone(&session), wake_rx, + app_handle, )); session @@ -471,6 +500,7 @@ async fn run_session( auth_tag: Option, session: Arc, mut wake_rx: mpsc::Receiver<()>, + app_handle: Option, ) { let mut delay = RECONNECT_BASE_DELAY; loop { @@ -478,8 +508,33 @@ async fn run_session( return; } - let connecting = - NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()); + let connecting = async { + let employee = + if auth_tag.is_none() && crate::builderlab::requires_employee(&relay_url)? { + use tauri::Manager; + let handle = app_handle + .as_ref() + .ok_or("Employee identity context unavailable")?; + let state = handle.state::(); + crate::builderlab::employee_evidence(&state, &keys, &relay_url).await? + } else { + None + }; + let connection = if let Some(evidence) = &employee { + tokio::select! { + biased; + _ = evidence.cancel.cancelled() => return Err("Square sign-in changed".to_owned()), + result = NostrWsConnection::connect_authenticated_with_nip_fi( + &relay_url, &keys, &evidence.header, Duration::from_secs(30), + ) => result.map_err(|_| "Employee relay connection refused".to_owned())?, + } + } else { + NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()) + .await + .map_err(|error| error.to_string())? + }; + Ok::<_, String>((connection, employee)) + }; tokio::pin!(connecting); let connected = loop { tokio::select! { @@ -489,13 +544,32 @@ async fn run_session( } }; match connected { - Ok(conn) => { + Ok((conn, employee)) => { // A connection that authenticated is healthy regardless of how // long it then lived, so backoff resets here rather than on // clean exit — a socket that drops after one event must not // inherit the previous failure's delay. delay = RECONNECT_BASE_DELAY; - run_connection(conn, &session, &mut wake_rx).await; + if let Some(evidence) = employee { + if evidence.cancel.is_cancelled() + || evidence.expires_at <= chrono::Utc::now().timestamp() + { + continue; + } + let lifetime = Duration::from_secs( + evidence + .expires_at + .saturating_sub(chrono::Utc::now().timestamp()) + .max(0) as u64, + ); + tokio::select! { + _ = evidence.cancel.cancelled()=>{}, + _ = tokio::time::sleep(lifetime)=>{}, + _ = run_connection(conn,&session,&mut wake_rx, true)=>{}, + } + } else { + run_connection(conn, &session, &mut wake_rx, false).await; + } } Err(error) => { eprintln!("buzz-desktop: native_relay_client: connect failed: {error}"); @@ -523,6 +597,7 @@ async fn run_connection( mut conn: NostrWsConnection, session: &RelaySession, wake_rx: &mut mpsc::Receiver<()>, + protected: bool, ) { // Subscription ids currently open ON THIS SOCKET. Deliberately local: a new // socket has none, so reconnect resubscribes the full desired set without @@ -693,7 +768,8 @@ async fn run_connection( } } if let Some(request) = requests.remove(&subscription_id) { - let _ = request.complete.send(Err(format!("relay closed request: {message}"))); + let error = if protected { "employee relay closed request".to_owned() } else { format!("relay closed request: {message}") }; + let _ = request.complete.send(Err(error)); let mut state = session.state.lock().await; state.transient.retain(|subscription| subscription.id != subscription_id); state.removed.insert(subscription_id.clone()); @@ -704,9 +780,9 @@ async fn run_connection( drop(requests); let retry = retries.entry(subscription_id.clone()).or_default(); retry.schedule(&message); - eprintln!( - "buzz-desktop: native_relay_client: relay closed {subscription_id}: {message}" - ); + if !protected { + eprintln!("buzz-desktop: native_relay_client: relay closed {subscription_id}: {message}"); + } } Ok(RelayMessage::Eose { subscription_id }) => { // The relay served this subscription, so whatever @@ -753,7 +829,11 @@ async fn run_connection( Ok(_) => {} Err(error) => { if !is_read_timeout(&error) { - eprintln!("buzz-desktop: native_relay_client: read failed: {error}"); + if protected { + eprintln!("buzz-desktop: native_relay_client: employee relay read failed"); + } else { + eprintln!("buzz-desktop: native_relay_client: read failed: {error}"); + } return; } } diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index a7a51fb2904..cea30a81ef6 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -131,15 +131,39 @@ async fn open_connection( manager: &WebSocketManager, url: &str, on_message: Channel, + employee: Option, + connect_generation: Option, ) -> Result { - let connect_cancel = manager.connect_cancel.lock().await.clone(); + let connect_cancel = match connect_generation { + Some(token) => token, + None => manager.connect_cancel.lock().await.clone(), + }; + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut request = url.into_client_request().map_err(|_| "Invalid relay URL")?; + if let Some(evidence) = &employee { + request + .headers_mut() + .insert("Nostr-Federated-Identity", evidence.header.clone()); + } + let protected = employee.is_some(); + let employee_cancel = employee + .as_ref() + .map(|value| value.cancel.clone()) + .unwrap_or_default(); let (socket, _) = tokio::select! { + biased; + _ = employee_cancel.cancelled() => return Err("Square sign-in changed".to_owned()), _ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()), - result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(url)) => result + result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) => result .map_err(|_| "WebSocket connection timed out".to_string())? - .map_err(|error| error.to_string())?, + .map_err(|error| if protected { "Employee relay connection refused".to_owned() } else { error.to_string() })?, }; + if employee.as_ref().is_some_and(|value| { + value.cancel.is_cancelled() || value.expires_at <= chrono::Utc::now().timestamp() + }) { + return Err("Square sign-in changed".to_owned()); + } // Serialize registration with disconnect_all so a reload cannot miss a // connection that finished its handshake concurrently with teardown. let current_connect_cancel = manager.connect_cancel.lock().await; @@ -155,6 +179,23 @@ async fn open_connection( }; let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); let cancel = CancellationToken::new(); + let assertion = employee + .as_ref() + .and_then(|value| value.header.to_str().ok()) + .map(|value| value.trim_start_matches("Bearer ").to_owned()); + if let Some(evidence) = employee { + let connection_cancel = cancel.clone(); + tauri::async_runtime::spawn(async move { + let delay = Duration::from_secs( + evidence + .expires_at + .saturating_sub(chrono::Utc::now().timestamp()) + .max(0) as u64, + ); + tokio::select! { _ = evidence.cancel.cancelled()=>{}, _ = tokio::time::sleep(delay)=>{}, _ = connection_cancel.cancelled()=>return } + connection_cancel.cancel(); + }); + } let handle = Arc::new(ConnectionHandle { sender, cancel: cancel.clone(), @@ -171,6 +212,7 @@ async fn open_connection( cancel, on_message, task_manager, + assertion, )); *task_slot = Some(task); drop(task_slot); @@ -181,11 +223,30 @@ async fn open_connection( #[tauri::command] async fn connect( manager: tauri::State<'_, WebSocketManager>, + app_state: tauri::State<'_, crate::app_state::AppState>, url: String, on_message: Channel, _config: Option, ) -> Result { - open_connection(manager.inner(), &url, on_message).await + let connect_generation = manager.connect_cancel.lock().await.clone(); + let employee = if crate::builderlab::requires_employee(&url)? { + let keys = app_state.signing_keys()?; + tokio::select! { + biased; + _ = connect_generation.cancelled() => return Err("WebSocket connection cancelled".to_owned()), + result = crate::builderlab::employee_evidence(&app_state, &keys, &url) => result?, + } + } else { + None + }; + open_connection( + manager.inner(), + &url, + on_message, + employee, + Some(connect_generation), + ) + .await } pub(crate) async fn send_message( @@ -269,6 +330,7 @@ async fn run_connection( cancel: CancellationToken, on_message: Channel, manager: WebSocketManager, + protected_assertion: Option, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -276,6 +338,10 @@ async fn run_connection( loop { tokio::select! { _ = cancel.cancelled() => { + if let Ok(frame) = serde_json::to_string(&OutboundMessage::Close(None)) { + batch.push(frame); + batch.flush(&on_message); + } let _ = tokio::time::timeout( SHUTDOWN_TIMEOUT, socket.send(Message::Close(Some(CloseFrame { @@ -291,15 +357,23 @@ async fn run_connection( let result = tokio::time::timeout(WRITE_TIMEOUT, socket.send(request.message)) .await .map_err(|_| "WebSocket send timed out".to_string()) - .and_then(|result| result.map_err(|error| error.to_string())); + .and_then(|result| result.map_err(|error| if protected_assertion.is_some() { "Employee relay write failed".to_owned() } else { error.to_string() })); let failed = result.is_err(); let _ = request.result.send(result); if failed { break; } } incoming = socket.next() => { let message = match incoming { - Some(Ok(message)) => outbound_message(message), - Some(Err(error)) => OutboundMessage::Error(error.to_string()), + Some(Ok(message)) => { + let reflected = protected_assertion.as_ref().is_some_and(|token|match &message { + Message::Text(text)=>text.contains(token) || serde_json::from_str::(text).ok().is_some_and(|value| contains_assertion(&value, token)), + Message::Binary(bytes) | Message::Ping(bytes) | Message::Pong(bytes)=>bytes.windows(token.len()).any(|window|window==token.as_bytes()), + Message::Close(Some(frame))=>frame.reason.contains(token), + _=>false, + }); + if reflected { OutboundMessage::Error("Invalid employee relay response".to_owned()) } else { outbound_message(message) } + }, + Some(Err(error)) => OutboundMessage::Error(if protected_assertion.is_some() { "Employee relay connection failed".to_owned() } else { error.to_string() }), None => OutboundMessage::Close(None), }; let terminal = matches!(message, OutboundMessage::Close(_) | OutboundMessage::Error(_)); @@ -332,9 +406,23 @@ async fn run_connection( // A terminal frame already flushed; this covers cancellation and send // failure, which must not strand frames the relay already delivered. batch.flush(&on_message); + cancel.cancel(); manager.remove(id).await; } +fn contains_assertion(value: &serde_json::Value, token: &str) -> bool { + match value { + serde_json::Value::String(value) => value.contains(token), + serde_json::Value::Array(values) => { + values.iter().any(|value| contains_assertion(value, token)) + } + serde_json::Value::Object(values) => values + .iter() + .any(|(key, value)| key.contains(token) || contains_assertion(value, token)), + _ => false, + } +} + fn outbound_message(message: Message) -> OutboundMessage { match message { Message::Text(value) => OutboundMessage::Text(value.to_string()), @@ -381,7 +469,7 @@ mod tests { /// Records each delivery as its raw JSON payload, so tests assert on what /// the renderer actually receives rather than on internal batch state. - fn recording_channel() -> ( + pub(super) fn recording_channel() -> ( Channel, Arc>>, ) { @@ -434,6 +522,7 @@ mod tests { cancel.clone(), channel, manager, + None, )); Self { server, @@ -580,6 +669,7 @@ mod tests { handle.cancel.clone(), channel, manager.clone(), + None, )); *handle.task.lock().await = Some(task); @@ -640,9 +730,15 @@ mod tests { }); let manager = WebSocketManager::default(); - let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) - .await - .unwrap(); + let id = open_connection( + &manager, + &format!("ws://{address}"), + silent_channel(), + None, + None, + ) + .await + .unwrap(); send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) .await .unwrap(); @@ -684,6 +780,7 @@ mod tests { handle.cancel.clone(), silent_channel(), manager.clone(), + None, )); *handle.task.lock().await = Some(task); @@ -805,3 +902,6 @@ mod tests { drop(blocked_receiver); } } + +#[cfg(test)] +mod employee_tests; diff --git a/desktop/src-tauri/src/native_websocket/employee_tests.rs b/desktop/src-tauri/src/native_websocket/employee_tests.rs new file mode 100644 index 00000000000..a21def908d1 --- /dev/null +++ b/desktop/src-tauri/src/native_websocket/employee_tests.rs @@ -0,0 +1,150 @@ +use super::tests::recording_channel; +use super::*; + +#[tokio::test] +#[allow(clippy::result_large_err)] // Tungstenite fixes the handshake callback's error type. +async fn employee_upgrade_keeps_native_evidence_out_of_ipc_and_closes_on_logout() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (observed_tx, observed_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut observed = None; + let mut socket = tokio_tungstenite::accept_hdr_async( + stream, + |request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| { + let values = request + .headers() + .get_all("Nostr-Federated-Identity") + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect::>(); + observed = Some(values); + Ok(response) + }, + ) + .await + .unwrap(); + observed_tx.send(observed.unwrap()).unwrap(); + socket + .send(Message::Text("public frame".into())) + .await + .unwrap(); + while let Some(message) = socket.next().await { + if matches!(message, Ok(Message::Close(_))) { + break; + } + } + }); + let token = CancellationToken::new(); + let mut header = reqwest::header::HeaderValue::from_static("Bearer private.native.assertion"); + header.set_sensitive(true); + let evidence = crate::builderlab::EmployeeEvidence { + header, + cancel: token.clone(), + expires_at: chrono::Utc::now().timestamp() + 120, + }; + let manager = WebSocketManager::default(); + let (channel, deliveries) = recording_channel(); + let id = open_connection( + &manager, + &format!("ws://{address}"), + channel, + Some(evidence), + None, + ) + .await + .unwrap(); + assert_eq!( + observed_rx.await.unwrap(), + vec!["Bearer private.native.assertion"] + ); + tokio::time::timeout(Duration::from_secs(1), async { + while deliveries.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + token.cancel(); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.connections.lock().await.contains_key(&id) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let renderer = deliveries.lock().unwrap().join(""); + assert!(renderer.contains("public frame")); + assert!( + renderer.contains("Close"), + "logout must immediately notify renderer teardown" + ); + assert!(!renderer.contains("private.native.assertion")); +} + +#[tokio::test] +async fn employee_relay_reflection_is_refused_before_the_renderer_channel() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + socket + .send(Message::Text( + r#"["AUTH","private\u002enative\u002eassertion"]"#.into(), + )) + .await + .unwrap(); + while socket.next().await.is_some() {} + }); + let evidence = crate::builderlab::EmployeeEvidence { + header: reqwest::header::HeaderValue::from_static("Bearer private.native.assertion"), + cancel: CancellationToken::new(), + expires_at: chrono::Utc::now().timestamp() + 120, + }; + let manager = WebSocketManager::default(); + let (channel, deliveries) = recording_channel(); + let id = open_connection( + &manager, + &format!("ws://{address}"), + channel, + Some(evidence), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.connections.lock().await.contains_key(&id) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let renderer = deliveries.lock().unwrap().join(""); + assert!(renderer.contains("Invalid employee relay response")); + assert!(!renderer.contains("private.native.assertion")); + server.abort(); +} + +#[tokio::test] +async fn employee_socket_cannot_adopt_a_new_generation_after_reload() { + let manager = WebSocketManager::default(); + let generation = manager.connect_cancel.lock().await.clone(); + generation.cancel(); + *manager.connect_cancel.lock().await = CancellationToken::new(); + let result = open_connection( + &manager, + "ws://127.0.0.1:1", + recording_channel().0, + None, + Some(generation), + ) + .await; + assert!(result.unwrap_err().contains("cancelled")); + assert!(manager.connections.lock().await.is_empty()); +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index e11c33637ff..217c92c4410 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -117,16 +117,6 @@ pub fn relay_api_base_url() -> String { // ── NIP-98 HTTP auth ──────────────────────────────────────────────────────── -pub fn build_nip98_auth_header( - method: &Method, - url: &str, - body: &[u8], - state: &AppState, -) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; - build_nip98_auth_header_for_keys(&keys, method, url, body) -} - pub fn build_nip98_auth_header_for_keys( keys: &Keys, method: &Method, @@ -266,9 +256,20 @@ pub(crate) async fn parse_json_response( // malformed body, so route it through `classify_body_timeout` — the same // helper the non-2xx error-body path uses — to preserve the stable // "relay unreachable: request timed out" label. - response.json::().await.map_err(|e| { - classify_body_timeout(&e).unwrap_or_else(|| MALFORMED_RESPONSE_MESSAGE.to_string()) - }) + let assertion = response + .extensions() + .get::() + .cloned(); + let value: serde_json::Value = + crate::builderlab::guard_employee_response(assertion.clone(), response.json()) + .await? + .map_err(|e| { + classify_body_timeout(&e).unwrap_or_else(|| MALFORMED_RESPONSE_MESSAGE.to_string()) + })?; + if assertion.is_some_and(|assertion| value.to_string().contains(&assertion.0)) { + return Err("Invalid employee relay response".to_owned()); + } + serde_json::from_value(value).map_err(|_| MALFORMED_RESPONSE_MESSAGE.to_owned()) } /// Extract the `retry in Ns` hint from a rate-limit error string. @@ -284,6 +285,11 @@ fn extract_retry_in_hint(body: &str) -> Option { pub async fn relay_error_message(response: reqwest::Response) -> String { let status = response.status(); + let scope = response + .extensions() + .get::() + .cloned(); + let protected = scope.is_some(); // Check for intercepted/proxy responses before reading the body. let final_host = response.url().host_str().unwrap_or("").to_string(); @@ -305,9 +311,10 @@ pub async fn relay_error_message(response: reqwest::Response) -> String { // shared helper instead of letting `unwrap_or_default` swallow it into a // bare status label. A non-timeout body error still degrades to an empty // body → status-only message, exactly as before. - let body = match response.text().await { - Ok(body) => body, - Err(e) => { + let body = match crate::builderlab::guard_employee_response(scope, response.text()).await { + Ok(Ok(body)) => body, + Err(error) => return error, + Ok(Err(e)) => { if let Some(timeout) = classify_body_timeout(&e) { return timeout; } @@ -334,6 +341,9 @@ pub async fn relay_error_message(response: reqwest::Response) -> String { return "relay rate-limited: quota exceeded".to_string(); } + if protected { + return format!("employee relay returned {status}"); + } if let Ok(value) = serde_json::from_str::(&body) { if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) { return format!("relay returned {status}: {message}"); @@ -374,14 +384,16 @@ pub async fn query_relay_at( let url = format!("{}/query", api_base_url); let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; - let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; + let keys = state.signing_keys()?; + let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)?; send_query_request( - &state.http_client, + &state.media_fetch_client, &url, &auth, None, body_bytes, QUERY_REQUEST_TIMEOUT, + Some((state, &keys)), ) .await } @@ -399,12 +411,17 @@ pub async fn query_relay_at_with_keys( serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; send_query_request( - &state.http_client, + &state.media_fetch_client, &url, &auth, auth_tag, body_bytes, QUERY_REQUEST_TIMEOUT, + if auth_tag.is_some() { + None + } else { + Some((state, keys)) + }, ) .await } @@ -455,8 +472,9 @@ async fn send_query_request( auth_tag: Option<&str>, body_bytes: Vec, timeout: std::time::Duration, + employee: Option<(&AppState, &Keys)>, ) -> Result, String> { - let response = build_authenticated_relay_request( + let request = build_authenticated_relay_request( http_client, Method::POST, url, @@ -464,10 +482,15 @@ async fn send_query_request( Some(body_bytes), auth_tag, Some(timeout), - ) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + ); + let response = if let Some((state, keys)) = employee { + crate::builderlab::send_employee_request(state, keys, url, request).await? + } else { + request + .send() + .await + .map_err(|e| classify_request_error(&e))? + }; if !response.status().is_success() { return Err(relay_error_message(response).await); } @@ -708,7 +731,7 @@ pub async fn submit_signed_event_with_keys( let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let mut request = state - .http_client + .media_fetch_client .post(&url) .header("Authorization", auth_header) .header("Content-Type", "application/json"); @@ -716,11 +739,16 @@ pub async fn submit_signed_event_with_keys( request = request.header("x-auth-tag", tag); } - let response = request - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let response = if auth_tag.is_some() { + request + .body(body_bytes) + .send() + .await + .map_err(|error| classify_request_error(&error))? + } else { + crate::builderlab::send_employee_request(state, keys, &url, request.body(body_bytes)) + .await? + }; if !response.status().is_success() { return Err(relay_error_message(response).await); diff --git a/desktop/src-tauri/src/relay/get.rs b/desktop/src-tauri/src/relay/get.rs index 95226d3bfcd..9ee99fa9a86 100644 --- a/desktop/src-tauri/src/relay/get.rs +++ b/desktop/src-tauri/src/relay/get.rs @@ -4,8 +4,8 @@ use serde::de::DeserializeOwned; use crate::app_state::AppState; use super::{ - build_authenticated_relay_request, build_nip98_auth_header, classify_request_error, - parse_json_response, relay_api_base_url_with_override, relay_error_message, + build_authenticated_relay_request, build_nip98_auth_header_for_keys, parse_json_response, + relay_api_base_url_with_override, relay_error_message, }; /// Execute an authenticated GET against the active relay and decode its JSON body. @@ -22,19 +22,18 @@ pub async fn get_relay_json( relay_api_base_url_with_override(state), path_with_query ); - let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; - let response = build_authenticated_relay_request( - &state.http_client, + let keys = state.signing_keys()?; + let auth = build_nip98_auth_header_for_keys(&keys, &Method::GET, &url, &[])?; + let request = build_authenticated_relay_request( + &state.media_fetch_client, Method::GET, &url, &auth, None, None, None, - ) - .send() - .await - .map_err(|error| classify_request_error(&error))?; + ); + let response = crate::builderlab::send_employee_request(state, &keys, &url, request).await?; if !response.status().is_success() { return Err(relay_error_message(response).await); } diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index a6b79a6cd2d..4ab07daaae0 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -28,18 +28,16 @@ pub async fn submit_signed_event_at_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let response = build_authenticated_relay_request( - &state.http_client, + let request = build_authenticated_relay_request( + &state.media_fetch_client, Method::POST, &url, &auth_header, Some(body_bytes), None, None, - ) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + ); + let response = crate::builderlab::send_employee_request(state, keys, &url, request).await?; if !response.status().is_success() { return Err(relay_error_message(response).await); diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index 251845e4310..30e698c6ba9 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -446,6 +446,7 @@ async fn stalled_query_request_times_out_with_classified_error() { None, b"[]".to_vec(), Duration::from_millis(200), + None, ), ) .await @@ -506,6 +507,7 @@ async fn stalled_response_body_times_out_with_classified_error() { None, b"[]".to_vec(), Duration::from_millis(200), + None, ), ) .await @@ -567,6 +569,7 @@ async fn stalled_error_response_body_times_out_with_classified_error() { None, b"[]".to_vec(), Duration::from_millis(200), + None, ), ) .await @@ -624,6 +627,7 @@ async fn non_stalled_error_response_yields_status_message() { None, b"[]".to_vec(), Duration::from_millis(200), + None, ), ) .await diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 581987ed610..7bd9c03bc23 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -24,6 +24,9 @@ import { useReloadShortcut } from "@/app/useReloadShortcut"; import { useCloseWindowShortcut } from "@/app/useCloseWindowShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { SquareEmployeeSessionGate } from "@/features/settings/ui/SquareEmployeeSessionGate"; +import { useSquareEmployeeSession } from "@/features/settings/ui/useSquareEmployeeSession"; +import { Button } from "@/shared/ui/button"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -363,11 +366,13 @@ function CommunityApp({ continueOnboarding, currentPubkey, onBackToMachineConfig, + onRecoverIdentity, sharedIdentity, }: { continueOnboarding: boolean; currentPubkey: string | null; onBackToMachineConfig: () => void; + onRecoverIdentity: () => void; sharedIdentity: boolean; }) { const { @@ -436,6 +441,13 @@ function CommunityApp({ communities, ); + const communityApplied = + community.isReady && community.appliedKey === communityKey; + const employeeSession = useSquareEmployeeSession( + communityApplied, + communityKey, + ); + const transitionCommunity = useCallback( async (targetCommunityId: string) => { const activeCommunityId = activeCommunity?.id; @@ -547,12 +559,18 @@ function CommunityApp({ community.isReady && community.appliedKey === communityKey; useEffect(() => { - if (transaction?.stage !== "connecting" || !targetIsReady) return; + if ( + transaction?.stage !== "connecting" || + !targetIsReady || + !employeeSession.ready + ) + return; const transactionId = transaction.id; const relayUrl = transaction.relayUrl; if (profileCheckTransactionRef.current === transactionId) return; profileCheckTransactionRef.current = transactionId; + let canceled = false; // resolveProfileCheckAction resolves exactly once (Promise.race + timer // cleared on settle), so no settled flag is needed here. void resolveProfileCheckAction(getProfile, 10_000).then((result) => { @@ -560,7 +578,11 @@ function CommunityApp({ // transaction must still be the same one that launched this request // AND still be in connecting. Covers cancel+replacement (B's ID !== A's) // and cancel-without-replacement (transactionRef.current is null). - if (!isTransactionStillConnecting(transactionRef.current, transactionId)) + if ( + canceled || + !employeeSession.isCurrent() || + !isTransactionStillConnecting(transactionRef.current, transactionId) + ) return; if (result.action === "skip") { @@ -573,9 +595,16 @@ function CommunityApp({ ); } }); + return () => { + canceled = true; + if (profileCheckTransactionRef.current === transactionId) + profileCheckTransactionRef.current = null; + }; }, [ communityOnboarding, targetIsReady, + employeeSession.ready, + employeeSession.isCurrent, transaction?.stage, transaction?.id, transaction?.relayUrl, @@ -611,18 +640,11 @@ function CommunityApp({ } else if ("error" in community && community.error) { // Surface apply failures so the user can retry or change community. appContent = ( - <> - setIsCommunityChangeOpen(true)} - onRetry={reconnectCommunity} - /> - {isCommunityChangeOpen ? ( - setIsCommunityChangeOpen(false)} - /> - ) : null} - + setIsCommunityChangeOpen(true)} + onRetry={reconnectCommunity} + /> ); } } @@ -630,8 +652,6 @@ function CommunityApp({ // rendering anything that connects to the relay. The appliedKey check avoids // a one-render race where React sees the new active community while the // Tauri backend is still configured for the previous one. - const communityApplied = - community.isReady && community.appliedKey === communityKey; useLayoutEffect(() => { if (communityApplied) { completeCommunityViewTransition(); @@ -677,19 +697,66 @@ function CommunityApp({ return ( <> - {appContent} - {transaction ? ( -
- -
+ + {communities + .filter((saved) => saved.id !== activeCommunity?.id) + .map((saved) => ( + + ))} + + + {transaction ? ( + + ) : null} + + } + > + {appContent} + {transaction ? ( +
+ +
+ ) : null} +
+ {isCommunityChangeOpen ? ( + setIsCommunityChangeOpen(false)} + /> ) : null} ); @@ -714,6 +781,12 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { machine.reopen(); }, [machine.reopen]); + const recoverIdentity = useCallback(() => { + setContinueOnboarding(false); + setMachineInitialPage("key-import"); + machine.reopen(); + }, [machine.reopen]); + const completeMachineOnboarding = useCallback( (pubkey?: string, options?: { continueToProfile?: boolean }) => { setContinueOnboarding(options?.continueToProfile === true); @@ -762,6 +835,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { continueOnboarding={continueOnboarding} currentPubkey={machine.currentPubkey} onBackToMachineConfig={reopenMachineConfig} + onRecoverIdentity={recoverIdentity} sharedIdentity={sharedIdentity} /> ); diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 18b7e938fcc..45aead04748 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -76,6 +76,7 @@ import { SettingsOptionRow, } from "./SettingsOptionGroup"; import { SegmentedControl } from "@/shared/ui/segmented-control"; +import { SquareEmployeeSignInCard } from "./SquareEmployeeSignInCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; @@ -806,10 +807,13 @@ export function renderSettingsSection( switch (section) { case "profile": return ( - +
+ + +
); case "notifications": return ( diff --git a/desktop/src/features/settings/ui/SquareEmployeeSessionGate.tsx b/desktop/src/features/settings/ui/SquareEmployeeSessionGate.tsx new file mode 100644 index 00000000000..65bfd808116 --- /dev/null +++ b/desktop/src/features/settings/ui/SquareEmployeeSessionGate.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from "react"; +import { Button } from "@/shared/ui/button"; +import { SquareEmployeeSignInCard } from "./SquareEmployeeSignInCard"; +import { + useSquareEmployeeSession, + type SquareEmployeeSessionState, +} from "./useSquareEmployeeSession"; + +/** Protects relay content while keeping local community and identity recovery available. */ +export function SquareEmployeeSessionGate({ + active, + scope, + session, + recovery, + children, +}: { + active: boolean; + scope: string; + session?: SquareEmployeeSessionState; + recovery?: ReactNode; + children: ReactNode; +}) { + if (!active) return children; + if (session) + return ( + + {children} + + ); + return ( + + {children} + + ); +} + +function ActiveSessionGate({ + scope, + recovery, + children, +}: { + scope: string; + recovery?: ReactNode; + children: ReactNode; +}) { + const session = useSquareEmployeeSession(true, scope); + return ( + + {children} + + ); +} + +function SessionGateContent({ + session, + recovery, + children, +}: { + session: SquareEmployeeSessionState; + recovery?: ReactNode; + children: ReactNode; +}) { + if (session.ready) return children; + return ( +
+
+ {session.error ? ( + <> +

Square access could not be checked.

+ + + ) : null} + {session.status?.required ? ( + + ) : ( +

Checking community access…

+ )} + {recovery} +
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SquareEmployeeSignInCard.tsx b/desktop/src/features/settings/ui/SquareEmployeeSignInCard.tsx new file mode 100644 index 00000000000..c2e503556a9 --- /dev/null +++ b/desktop/src/features/settings/ui/SquareEmployeeSignInCard.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import { Button } from "@/shared/ui/button"; +import { useSquareEmployeeSession } from "./useSquareEmployeeSession"; + +/** Explicitly enabled employee sign-in; the native process retains all session credentials. */ +export function SquareEmployeeSignInCard({ + onConnected, +}: { + onConnected?: () => void; +}) { + const [available, setAvailable] = useState(false); + const [pending, setPending] = useState(false); + const session = useSquareEmployeeSession( + available, + "square-employee-settings", + ); + const signedIn = session.status?.authenticated === true && session.ready; + const canReconnect = signedIn || session.status?.canRetry === true; + const [error, setError] = useState(null); + const generation = useRef(0); + useEffect(() => { + let active = true; + void invoke("employee_sign_in_available") + .then((value) => { + if (active) setAvailable(value); + }) + .catch(() => { + if (active) + setError("Square sign-in availability could not be checked."); + }); + return () => { + active = false; + generation.current += 1; + }; + }, []); + if (!available) return error ?

{error}

: null; + const signIn = async () => { + const ownGeneration = ++generation.current; + setPending(true); + setError(null); + try { + await invoke("start_employee_login"); + if (generation.current !== ownGeneration) return; + session.refresh(); + await relayClient.reconnect(); + if (generation.current === ownGeneration) { + window.dispatchEvent(new Event("square-employee-auth-changed")); + onConnected?.(); + } + } catch (failure) { + if (generation.current === ownGeneration) setError(String(failure)); + } finally { + if (generation.current === ownGeneration) setPending(false); + } + }; + const cancel = async () => { + generation.current += 1; + try { + await invoke("cancel_builderlab_login"); + setPending(false); + } catch { + setError("Could not cancel sign-in. Try again."); + } + }; + const signOut = async () => { + generation.current += 1; + try { + await invoke("clear_builderlab_auth"); + relayClient.disconnect(); + session.refresh(); + window.dispatchEvent(new Event("square-employee-auth-changed")); + } catch { + setError("Could not sign out. Try again."); + } + }; + const reconnect = async () => { + const ownGeneration = ++generation.current; + setPending(true); + setError(null); + try { + await invoke("refresh_employee_relay_access"); + if (generation.current !== ownGeneration) return; + session.refresh(); + await relayClient.reconnect(); + if (generation.current === ownGeneration) { + window.dispatchEvent(new Event("square-employee-auth-changed")); + onConnected?.(); + } + } catch (failure) { + if (generation.current === ownGeneration) setError(String(failure)); + } finally { + if (generation.current === ownGeneration) setPending(false); + } + }; + return ( +
+

+ Square team member access +

+

+ Sign in with your own Square account to use the access your employer + prepared. +

+ {signedIn ? ( +

+ Square session and your Buzz key are connected. If community admission + is still pending, retry the connection in a moment. +

+ ) : null} + {error ?

{error}

: null} + {session.error ? ( + <> +

Square session status could not be checked.

+ + + ) : null} + + {pending ? ( + + ) : null} + {canReconnect ? ( + <> + + + + ) : null} +
+ ); +} diff --git a/desktop/src/features/settings/ui/squareEmployeeSession.jsdom-test.mjs b/desktop/src/features/settings/ui/squareEmployeeSession.jsdom-test.mjs new file mode 100644 index 00000000000..fb4216a52ea --- /dev/null +++ b/desktop/src/features/settings/ui/squareEmployeeSession.jsdom-test.mjs @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { fireEvent } from "@testing-library/react"; +import { relayClient } from "@/shared/api/relayClient"; +import { SquareEmployeeSessionGate } from "./SquareEmployeeSessionGate.tsx"; +import { SquareEmployeeSignInCard } from "./SquareEmployeeSignInCard.tsx"; + +const handlers = new Map(); +const calls = []; +const callbacks = new Map(); +const listeners = new Map(); +let nextId = 0; +const ipc = { + invoke(command, args) { + calls.push([command, args]); + if (command === "plugin:event|listen") { + const id = ++nextId; + listeners.set(id, { + event: args.event, + callback: callbacks.get(args.handler), + }); + return Promise.resolve(id); + } + if (command === "plugin:event|unlisten") { + listeners.delete(args.eventId); + return Promise.resolve(); + } + return ( + handlers.get(command)?.(args) ?? + Promise.reject(new Error(`Unexpected command: ${command}`)) + ); + }, + transformCallback(callback) { + const id = ++nextId; + callbacks.set(id, callback); + return id; + }, + unregisterCallback(id) { + callbacks.delete(id); + }, +}; +window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { + unregisterListener(_event, id) { + listeners.delete(id); + }, +}; +window.__TAURI_INTERNALS__ = ipc; +globalThis.__TAURI_INTERNALS__ = ipc; +const originalDisconnect = relayClient.disconnect; +const originalReconnect = relayClient.reconnect; +let root; +let container; +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; +const settle = async () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +async function mount(element) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render(element)); + await settle(); +} +async function click(label) { + const button = [...container.querySelectorAll("button")].find( + (element) => element.textContent === label, + ); + assert.ok(button, `Missing button: ${label}`); + await act(async () => fireEvent.click(button)); + await settle(); +} +const gate = (scope) => + React.createElement( + SquareEmployeeSessionGate, + { active: true, scope }, + React.createElement("p", null, "Community chat"), + ); +beforeEach(() => { + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated: false, + expiresAt: null, + })); +}); +afterEach(async () => { + if (root) await act(async () => root.unmount()); + container?.remove(); + root = undefined; + handlers.clear(); + calls.length = 0; + relayClient.disconnect = originalDisconnect; + relayClient.reconnect = originalReconnect; + assert.equal(listeners.size, 0, "native session listeners are released"); + callbacks.clear(); +}); + +test("ordinary communities retain the existing onboarding path", async () => { + handlers.set("employee_session_status", async () => ({ + required: false, + authenticated: false, + expiresAt: null, + })); + await mount(gate("ordinary")); + assert.match(container.textContent, /Community chat/); + assert.equal( + calls.filter(([name]) => name === "employee_sign_in_available").length, + 0, + ); +}); + +test("employee sign-in is reachable before relay-dependent onboarding", async () => { + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated: false, + expiresAt: null, + })); + handlers.set("employee_sign_in_available", async () => true); + await mount(gate("employee")); + assert.doesNotMatch(container.textContent, /Community chat/); + assert.match(container.textContent, /Sign in with Square/); +}); + +test("a late access check from a previous community cannot unlock the current one", async () => { + const old = deferred(); + let count = 0; + handlers.set("employee_session_status", () => + ++count === 1 + ? old.promise + : Promise.resolve({ + required: true, + authenticated: false, + expiresAt: null, + }), + ); + handlers.set("employee_sign_in_available", async () => true); + await mount(gate("old")); + await act(async () => root.render(gate("current"))); + await settle(); + await act(async () => + old.resolve({ required: false, authenticated: false, expiresAt: null }), + ); + await settle(); + assert.doesNotMatch(container.textContent, /Community chat/); + assert.match(container.textContent, /Sign in with Square/); +}); + +test("an access check failure can be retried without opening chat", async () => { + handlers.set("employee_session_status", async () => { + throw new Error("offline"); + }); + await mount(gate("employee")); + assert.match(container.textContent, /Square access could not be checked/); + assert.doesNotMatch(container.textContent, /Community chat/); + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated: true, + expiresAt: Math.floor(Date.now() / 1000) + 240, + })); + await click("Retry access check"); + assert.match(container.textContent, /Community chat/); +}); + +test("cancel fences a late login result before relay connection", async () => { + const login = deferred(); + let connections = 0; + handlers.set("employee_sign_in_available", async () => true); + handlers.set("start_employee_login", () => login.promise); + handlers.set("cancel_builderlab_login", async () => undefined); + relayClient.disconnect = () => {}; + relayClient.reconnect = async () => { + connections += 1; + }; + await mount(React.createElement(SquareEmployeeSignInCard)); + await click("Sign in with Square"); + await click("Cancel sign-in"); + await act(async () => login.resolve({ expires_at: "public expiry" })); + await settle(); + assert.equal(connections, 0); + assert.doesNotMatch( + container.textContent, + /Square session and your Buzz key are connected/, + ); + assert.equal(container.textContent.includes("Cancel sign-in"), false); +}); + +test("community refusal keeps an explicit retry, and sign-out tears down native access", async () => { + let connects = 0; + let disconnects = 0; + let connected = 0; + handlers.set("employee_sign_in_available", async () => true); + let authenticated = false; + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated, + expiresAt: authenticated ? Math.floor(Date.now() / 1000) + 240 : null, + })); + handlers.set("start_employee_login", async () => { + authenticated = true; + return { expires_at: "public expiry" }; + }); + handlers.set("refresh_employee_relay_access", async () => undefined); + handlers.set("clear_builderlab_auth", async () => { + authenticated = false; + }); + relayClient.disconnect = () => { + disconnects += 1; + }; + relayClient.reconnect = async () => { + if (++connects === 1) throw new Error("Community admission is pending"); + }; + await mount( + React.createElement(SquareEmployeeSignInCard, { + onConnected: () => { + connected += 1; + }, + }), + ); + await click("Sign in with Square"); + assert.match(container.textContent, /Community admission is pending/); + assert.equal(connected, 0); + await click("Retry community connection"); + assert.equal(connected, 1); + await click("Sign out of Square"); + assert.equal( + disconnects, + 1, + "only sign-out tears down subscription ownership", + ); + assert.equal(connects, 2, "login and retry preserve live owners"); + assert.equal( + calls.filter(([name]) => name === "clear_builderlab_auth").length, + 1, + ); + assert.doesNotMatch( + container.textContent, + /Square session and your Buzz key are connected/, + ); +}); + +async function nativeSessionChanged() { + await act(async () => { + for (const [id, listener] of [...listeners]) + if (listener.event === "builderlab-session-changed") + listener.callback({ event: listener.event, id, payload: null }); + }); + await settle(); +} + +test("settings hydrates existing native access and every view reacts to shared sign-out", async () => { + let authenticated = true; + handlers.set("employee_sign_in_available", async () => true); + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated, + expiresAt: authenticated ? Math.floor(Date.now() / 1000) + 240 : null, + })); + await mount( + React.createElement( + React.Fragment, + null, + gate("employee"), + React.createElement(SquareEmployeeSignInCard), + ), + ); + assert.match(container.textContent, /Community chat/); + assert.match(container.textContent, /Sign out of Square/); + authenticated = false; + await nativeSessionChanged(); + assert.doesNotMatch(container.textContent, /Community chat/); + assert.doesNotMatch(container.textContent, /Sign out of Square/); + assert.match(container.textContent, /Sign in with Square/); +}); + +test("native change closes content during recheck and ignores an older successful response", async () => { + handlers.set("employee_sign_in_available", async () => true); + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated: true, + expiresAt: Math.floor(Date.now() / 1000) + 240, + })); + await mount(gate("employee")); + assert.match(container.textContent, /Community chat/); + const pending = deferred(); + handlers.set("employee_session_status", () => pending.promise); + await nativeSessionChanged(); + assert.doesNotMatch(container.textContent, /Community chat/); + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated: false, + expiresAt: null, + })); + await nativeSessionChanged(); + await act(async () => + pending.resolve({ + required: true, + authenticated: true, + expiresAt: Math.floor(Date.now() / 1000) + 240, + }), + ); + await settle(); + assert.doesNotMatch(container.textContent, /Community chat/); + assert.match(container.textContent, /Sign in with Square/); +}); + +test("local community and identity recovery stay reachable while access is refused", async () => { + let switched = 0, + recovered = 0, + canceled = 0; + handlers.set("employee_sign_in_available", async () => true); + const recovery = React.createElement( + "nav", + { "aria-label": "Community recovery" }, + React.createElement( + "button", + { type: "button", onClick: () => switched++ }, + "Switch to saved community", + ), + React.createElement( + "button", + { type: "button", onClick: () => recovered++ }, + "Recover Buzz identity", + ), + React.createElement( + "button", + { type: "button", onClick: () => canceled++ }, + "Cancel joining community", + ), + ); + await mount( + React.createElement( + SquareEmployeeSessionGate, + { active: true, scope: "employee", recovery }, + React.createElement("p", null, "Community chat"), + ), + ); + await click("Switch to saved community"); + await click("Recover Buzz identity"); + await click("Cancel joining community"); + assert.deepEqual([switched, recovered, canceled], [1, 1, 1]); + assert.doesNotMatch(container.textContent, /Community chat/); +}); + +test("refused relay access retains an explicit retry without claiming the employee is connected", async () => { + handlers.set("employee_sign_in_available", async () => true); + handlers.set("employee_session_status", async () => ({ + required: true, + authenticated: false, + canRetry: true, + expiresAt: null, + })); + handlers.set("refresh_employee_relay_access", async () => {}); + relayClient.reconnect = async () => {}; + await mount(React.createElement(SquareEmployeeSignInCard)); + assert.match(container.textContent, /Retry community connection/); + assert.doesNotMatch( + container.textContent, + /Square session and your Buzz key are connected/, + ); + await click("Retry community connection"); + assert.ok( + calls.some(([command]) => command === "refresh_employee_relay_access"), + ); + assert.ok(!calls.some(([command]) => command === "start_employee_login")); +}); diff --git a/desktop/src/features/settings/ui/useSquareEmployeeSession.ts b/desktop/src/features/settings/ui/useSquareEmployeeSession.ts new file mode 100644 index 00000000000..80bef0e6ebd --- /dev/null +++ b/desktop/src/features/settings/ui/useSquareEmployeeSession.ts @@ -0,0 +1,119 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export type SquareEmployeeSessionStatus = { + required: boolean; + authenticated: boolean; + canRetry?: boolean; + expiresAt: number | null; +}; + +function admitsCommunity(status: SquareEmployeeSessionStatus | null) { + return ( + status !== null && + (!status.required || + (status.authenticated && + status.expiresAt !== null && + status.expiresAt * 1000 > Date.now())) + ); +} + +/** Native session changes invalidate every webview before checking current access. */ +export function useSquareEmployeeSession(active: boolean, scope: string) { + const generation = useRef(0); + const listening = useRef(false); + const [listenerRevision, setListenerRevision] = useState(0); + const retry = useCallback( + () => setListenerRevision((value) => value + 1), + [], + ); + const [snapshot, setSnapshot] = useState<{ + scope: string; + revision: number; + status: SquareEmployeeSessionStatus | null; + error: boolean; + } | null>(null); + const refresh = useCallback(() => { + const ownGeneration = ++generation.current; + setSnapshot({ scope, revision: ownGeneration, status: null, error: false }); + void invoke("employee_session_status") + .then((status) => { + if (generation.current === ownGeneration) + setSnapshot({ scope, revision: ownGeneration, status, error: false }); + }) + .catch(() => { + if (generation.current === ownGeneration) + setSnapshot({ + scope, + revision: ownGeneration, + status: null, + error: true, + }); + }); + }, [scope]); + // biome-ignore lint/correctness/useExhaustiveDependencies: explicit retry must re-register a failed native event listener. + useEffect(() => { + if (!active) return; + let disposed = false; + let unlisten: (() => void) | undefined; + window.addEventListener("square-employee-auth-changed", refresh); + void listen("builderlab-session-changed", () => { + if (!disposed) refresh(); + }) + .then((stop) => { + if (disposed) { + stop(); + return; + } + unlisten = stop; + listening.current = true; + // Register first, then read: a mutation during registration cannot be lost. + refresh(); + }) + .catch(() => { + if (!disposed) { + const revision = ++generation.current; + setSnapshot({ scope, revision, status: null, error: true }); + } + }); + return () => { + disposed = true; + listening.current = false; + generation.current += 1; + window.removeEventListener("square-employee-auth-changed", refresh); + unlisten?.(); + }; + }, [active, scope, refresh, listenerRevision]); + const current = active && snapshot?.scope === scope ? snapshot : null; + useEffect(() => { + if (!current?.status?.expiresAt) return; + const remaining = current.status.expiresAt * 1000 - Date.now(); + if (remaining <= 0) return; + const timer = window.setTimeout(refresh, remaining); + return () => window.clearTimeout(timer); + }, [current, refresh]); + const revision = current?.revision ?? 0; + const status = current?.status ?? null; + const isCurrent = useCallback( + () => + active && + listening.current && + generation.current === revision && + admitsCommunity(status), + [active, revision, status], + ); + return { + status, + error: current?.error ?? false, + ready: listening.current && admitsCommunity(status), + revision, + refresh, + retry, + isCurrent, + }; +} + +export type SquareEmployeeSessionState = ReturnType< + typeof useSquareEmployeeSession +>; diff --git a/desktop/src/shared/api/relayClientEmployeeReconnect.test.mjs b/desktop/src/shared/api/relayClientEmployeeReconnect.test.mjs new file mode 100644 index 00000000000..e2567b98185 --- /dev/null +++ b/desktop/src/shared/api/relayClientEmployeeReconnect.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; + +const previousWindow = globalThis.window; +const previousInternals = globalThis.__TAURI_INTERNALS__; +const writes = []; +let receive; +const internals = { + transformCallback() { + return 1; + }, + async invoke(command, args) { + if (command === "plugin:websocket|connect") { + receive = args.onMessage.onmessage; + queueMicrotask(() => + receive({ type: "Text", data: JSON.stringify(["AUTH", "challenge"]) }), + ); + return 8; + } + if (command === "create_auth_event") + return JSON.stringify({ id: "auth-event", kind: 22242 }); + if (command === "plugin:websocket|send") { + const frame = JSON.parse(args.message.data); + writes.push({ socket: args.id, frame }); + if (frame[0] === "AUTH") + queueMicrotask(() => + receive({ + type: "Text", + data: JSON.stringify(["OK", frame[1].id, true, ""]), + }), + ); + if (frame[0] === "REQ" && args.id === 8) + queueMicrotask(() => + receive({ type: "Text", data: JSON.stringify(["EOSE", frame[1]]) }), + ); + return; + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`Unexpected IPC ${command}`); + }, +}; +globalThis.window = { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + __TAURI_INTERNALS__: internals, +}; +globalThis.__TAURI_INTERNALS__ = internals; +const { RelayClient } = await import("./relayClientSession.ts"); +after(() => { + globalThis.window = previousWindow; + globalThis.__TAURI_INTERNALS__ = previousInternals; +}); + +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; +test("employee reconnect replays mounted live subscriptions and preserves repair listeners", async () => { + const client = new RelayClient(); + client.wsId = 7; + client.relayUrl = "wss://employee.example"; + client.hasConnectedOnce = true; + const events = []; + let repairs = 0; + client.subscribeToReconnects(() => repairs++); + const pending = client.subscribeLive( + { kinds: [0], authors: ["employee-key"] }, + (event) => events.push(event), + ); + try { + await flush(); + const id = writes.find((write) => write.frame[0] === "REQ").frame[1]; + await client.handleWsMessage( + { type: "Text", data: JSON.stringify(["EOSE", id]) }, + client.connectionGeneration, + ); + const unsubscribe = await pending; + await client.reconnect(); + assert.ok( + writes.some( + (write) => + write.socket === 8 && + write.frame[0] === "REQ" && + write.frame[1] === id, + ), + ); + assert.equal(repairs, 1); + receive({ + type: "Text", + data: JSON.stringify([ + "EVENT", + id, + { + id: "new-event", + kind: 0, + pubkey: "employee-key", + created_at: 1, + content: "{}", + tags: [], + }, + ]), + }); + await flush(); + // Delivery is batched by the same production path used before reconnect. + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.deepEqual( + events.map((event) => event.id), + ["new-event"], + ); + unsubscribe(); + } finally { + client.disconnect(); + } +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index ce640375709..2f9f6590508 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -56,6 +56,7 @@ import { shouldWaitForScheduledReconnect, } from "@/shared/api/relayReconnectPolicy"; import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; +import { notifyRelayReconnectListeners } from "./relayReconnectListeners"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; import { AUTH_TIMEOUT_MS, @@ -438,6 +439,18 @@ export class RelayClient { ); } + /** Replace this community's socket while preserving live subscription owners. */ + async reconnect() { + this.resetConnection(new Error("Relay connection refreshed."), { + reconnect: false, + }); + this.connectPromise = null; + this.terminal = false; + this.authOkTracker.reset(); + this.keepAliveRequested = true; + this.notifyReconnectListeners = this.hasConnectedOnce; + await this.connectBypassingBackoff(); + } async preconnect() { // Explicit re-engagement (reconnect card / community switch): clears the // terminal latch and AUTH rejection streak, and bypasses backoff once. @@ -1102,23 +1115,10 @@ export class RelayClient { } private emitReconnectIfNeeded() { - const shouldNotifyReconnectListeners = - this.hasConnectedOnce && this.notifyReconnectListeners; - + const notify = this.hasConnectedOnce && this.notifyReconnectListeners; this.hasConnectedOnce = true; this.notifyReconnectListeners = false; - - if (!shouldNotifyReconnectListeners) { - return; - } - - for (const listener of this.reconnectListeners) { - try { - listener(); - } catch (error) { - console.error("Failed to handle relay reconnect", error); - } - } + notifyRelayReconnectListeners(notify, this.reconnectListeners); } private resetConnection( diff --git a/desktop/src/shared/api/relayReconnectListeners.ts b/desktop/src/shared/api/relayReconnectListeners.ts new file mode 100644 index 00000000000..dbd97b05712 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectListeners.ts @@ -0,0 +1,14 @@ +/** Notify mounted repair owners after an established connection is replaced. */ +export function notifyRelayReconnectListeners( + notify: boolean, + listeners: Iterable<() => void>, +) { + if (!notify) return; + for (const listener of listeners) { + try { + listener(); + } catch (error) { + console.error("Failed to handle relay reconnect", error); + } + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e03c85d40d4..f82c2863821 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12574,6 +12574,10 @@ export function maybeInstallE2eTauriMocks() { registry: await handleMockCommand("list_voice_registry", null), }; } + case "employee_session_status": + return { required: false, authenticated: false, expiresAt: null }; + case "employee_sign_in_available": + return false; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 5b5bdb63ea6..c99c15b493a 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -1134,3 +1134,120 @@ test("lifts Jump to latest when the composer grows", async ({ page }) => { (expandedPillBox?.y ?? 0) + (expandedPillBox?.height ?? 0), ).toBeLessThanOrEqual(expandedComposerBox?.y ?? 0); }); + +test("Square employee sign-in gates community onboarding and opens chat after native authorization", async ({ + page, +}, testInfo) => { + await page.addInitScript(() => { + let authenticated = false; + let profileReads = 0; + Object.defineProperty(window, "__EMPLOYEE_PROFILE_READS__", { + get: () => profileReads, + }); + const now = new Date().toISOString(); + window.localStorage.setItem( + "buzz-community-onboarding-transaction.v1", + JSON.stringify({ + id: "employee-existing-profile", + source: "add-community", + stage: "connecting", + relayUrl: "ws://localhost:3000", + communityName: "E2E Test", + communityId: "e2e-default-community", + createdAt: now, + updatedAt: now, + }), + ); + type Invoke = ( + command: string, + args?: unknown, + options?: unknown, + ) => Promise; + let internals: Record = {}; + const wrap = (next: Record) => + new Proxy(next, { + set(target, key, value) { + if (key === "invoke" && typeof value === "function") { + const original = value as Invoke; + target[key] = async ( + command: string, + args?: unknown, + options?: unknown, + ) => { + if (command === "get_profile") { + profileReads += 1; + if (!authenticated) + throw new Error( + "employee authentication required before profile check", + ); + } + if (command === "employee_session_status") + return { + required: true, + authenticated, + expiresAt: authenticated + ? Math.floor(Date.now() / 1000) + 240 + : null, + }; + if (command === "employee_sign_in_available") return true; + if (command === "start_employee_login") { + authenticated = true; + return { + expires_at: new Date(Date.now() + 240_000).toISOString(), + email: null, + name: null, + }; + } + return original(command, args, options); + }; + } else target[key as string] = value; + return true; + }, + }); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + get: () => internals, + set: (value: Record) => { + internals = wrap(value); + }, + }); + }); + await page.goto("/"); + await expect( + page.getByRole("button", { name: "Sign in with Square", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("app-sidebar")).toHaveCount(0); + expect( + await page.evaluate( + () => + (window as Window & { __EMPLOYEE_PROFILE_READS__?: number }) + .__EMPLOYEE_PROFILE_READS__, + ), + ).toBe(0); + await page.screenshot({ + path: testInfo.outputPath("square-employee-sign-in.png"), + }); + await page + .getByRole("button", { name: "Sign in with Square", exact: true }) + .click(); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + await expect(page.getByTestId("stream-list")).toContainText("general"); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __EMPLOYEE_PROFILE_READS__?: number }) + .__EMPLOYEE_PROFILE_READS__ ?? 0, + ), + ) + .toBeGreaterThan(0); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toHaveCount(0); + await page.screenshot({ + path: testInfo.outputPath("square-employee-chat.png"), + }); +});