From 58fd8e828fd5b7459b8fae574f4a9b763c641ea2 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:27:02 -0500 Subject: [PATCH 01/40] feat(relay): bind client status to authenticated sockets Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../buzz-core/src/client_binding_bootstrap.rs | 324 ++++++++++++++++++ crates/buzz-core/src/kind.rs | 4 + crates/buzz-core/src/lib.rs | 2 + .../src/authorization_runtime/status.rs | 7 +- crates/buzz-relay/src/connection.rs | 7 + crates/buzz-relay/src/handlers/auth.rs | 46 ++- crates/buzz-relay/src/handlers/event.rs | 1 + crates/buzz-relay/src/router.rs | 34 +- crates/buzz-relay/src/state.rs | 1 + 9 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 crates/buzz-core/src/client_binding_bootstrap.rs diff --git a/crates/buzz-core/src/client_binding_bootstrap.rs b/crates/buzz-core/src/client_binding_bootstrap.rs new file mode 100644 index 0000000000..1164cbe666 --- /dev/null +++ b/crates/buzz-core/src/client_binding_bootstrap.rs @@ -0,0 +1,324 @@ +//! Relay-authenticated connection bootstrap for client binding status. +//! +//! Kind `24245` is delivered only on the WebSocket connection whose native +//! client supplied the echoed epoch. It binds the relay's NIP-11 signing key, +//! the server-resolved authorization domain, and the authenticated event +//! author before kind `24244` status can be consumed. + +use std::fmt; + +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Timestamp}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{kind::KIND_CLIENT_BINDING_BOOTSTRAP, verify_event, CommunityId}; + +/// WebSocket upgrade header carrying a native-generated connection epoch. +pub const CLIENT_BINDING_EPOCH_HEADER: &str = "x-buzz-client-binding-epoch-v1"; +/// Reserved exact-connection subscription id for bootstrap delivery. +pub const CLIENT_BINDING_BOOTSTRAP_SUB_ID: &str = "__buzz_client_binding_bootstrap_v1__"; +/// Reserved exact-connection subscription id for status delivery. +pub const CLIENT_BINDING_STATUS_SUB_ID: &str = "__buzz_client_binding_status_v1__"; +/// Bootstrap wire version accepted by this module. +pub const CLIENT_BINDING_BOOTSTRAP_VERSION: u64 = 1; +/// Maximum encoded bootstrap payload length. +pub const MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES: usize = 1024; + +/// Opaque, native-generated 256-bit connection epoch. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ClientBindingEpoch(String); + +impl ClientBindingEpoch { + /// Construct an epoch from 32 CSPRNG bytes. + pub fn from_random_bytes(bytes: [u8; 32]) -> Self { + Self(hex::encode(bytes)) + } + + /// Parse the canonical 64-character lowercase hexadecimal wire form. + pub fn parse(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ClientBindingBootstrapError::InvalidConnectionEpoch); + } + Ok(Self(value.to_owned())) + } + + /// Canonical header and payload representation. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ClientBindingEpoch { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ClientBindingEpoch") + .field(&"[redacted]") + .finish() + } +} + +/// Validated relay-authenticated bootstrap. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientBindingBootstrapV1 { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + issued_at: u64, +} + +impl ClientBindingBootstrapV1 { + /// Server-resolved authorization domain pinned by this connection. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Authenticated event author pinned by this connection. + pub const fn event_author_pubkey(&self) -> PublicKey { + self.event_author_pubkey + } + + /// Echoed native connection epoch. + pub fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.connection_epoch + } + + /// Relay issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } +} + +impl fmt::Debug for ClientBindingBootstrapV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingBootstrapV1") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("connection_epoch", &"[redacted]") + .field("issued_at", &"[redacted]") + .finish() + } +} + +/// Validated server-side signing input for one connection bootstrap. +pub struct ClientBindingBootstrapInputV1 { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + issued_at: u64, +} + +impl ClientBindingBootstrapInputV1 { + /// Bind server-resolved connection authority to a native epoch. + pub fn new( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + issued_at: u64, + ) -> Result { + if authorization_domain.as_uuid().is_nil() { + return Err(ClientBindingBootstrapError::InvalidAuthorizationDomain); + } + if issued_at == 0 { + return Err(ClientBindingBootstrapError::InvalidIssueTime); + } + Ok(Self { + authorization_domain, + event_author_pubkey, + connection_epoch, + issued_at, + }) + } + + /// Sign the bootstrap with the relay key advertised by NIP-11 `self`. + pub fn sign_with_relay_keys( + self, + relay_keys: &Keys, + ) -> Result { + let wire = WireClientBindingBootstrapV1 { + version: CLIENT_BINDING_BOOTSTRAP_VERSION, + authorization_domain: self.authorization_domain.as_uuid().to_string(), + event_author_pubkey: self.event_author_pubkey.to_hex(), + connection_epoch: self.connection_epoch.0, + issued_at: self.issued_at, + }; + let content = serde_json::to_string(&wire) + .map_err(|_| ClientBindingBootstrapBuildError::Serialization)?; + if content.len() > MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES { + return Err(ClientBindingBootstrapBuildError::PayloadTooLarge); + } + EventBuilder::new(Kind::Custom(KIND_CLIENT_BINDING_BOOTSTRAP as u16), content) + .tags([]) + .custom_created_at(Timestamp::from(wire.issued_at)) + .sign_with_keys(relay_keys) + .map_err(|_| ClientBindingBootstrapBuildError::Signing) + } +} + +impl fmt::Debug for ClientBindingBootstrapInputV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingBootstrapInputV1") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("connection_epoch", &"[redacted]") + .field("issued_at", &"[redacted]") + .finish() + } +} + +#[derive(Deserialize)] +struct VersionHeader { + version: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WireClientBindingBootstrapV1 { + version: u64, + authorization_domain: String, + event_author_pubkey: String, + connection_epoch: String, + issued_at: u64, +} + +/// Authenticate and validate a connection bootstrap against native authority. +pub fn validate_client_binding_bootstrap_event( + event: &Event, + trusted_relay_pubkey: &PublicKey, + expected_connection_epoch: &ClientBindingEpoch, + expected_event_author_pubkey: &PublicKey, + now: u64, +) -> Result { + if event.kind.as_u16() as u32 != KIND_CLIENT_BINDING_BOOTSTRAP { + return Err(ClientBindingBootstrapError::WrongKind); + } + if event.content.len() > MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES { + return Err(ClientBindingBootstrapError::PayloadTooLarge); + } + verify_event(event).map_err(|_| ClientBindingBootstrapError::UnauthenticatedEvent)?; + if event.pubkey != *trusted_relay_pubkey { + return Err(ClientBindingBootstrapError::UnexpectedRelay); + } + if !event.tags.is_empty() { + return Err(ClientBindingBootstrapError::UnexpectedTags); + } + let header: VersionHeader = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingBootstrapError::MalformedPayload)?; + if header.version != CLIENT_BINDING_BOOTSTRAP_VERSION { + return Err(ClientBindingBootstrapError::UnsupportedVersion); + } + let wire: WireClientBindingBootstrapV1 = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingBootstrapError::MalformedPayload)?; + let authorization_domain = Uuid::parse_str(&wire.authorization_domain) + .map_err(|_| ClientBindingBootstrapError::InvalidAuthorizationDomain)?; + if authorization_domain.is_nil() + || authorization_domain.to_string() != wire.authorization_domain + { + return Err(ClientBindingBootstrapError::InvalidAuthorizationDomain); + } + let event_author_pubkey = parse_canonical_pubkey(&wire.event_author_pubkey)?; + if event_author_pubkey != *expected_event_author_pubkey { + return Err(ClientBindingBootstrapError::EventAuthorMismatch); + } + let connection_epoch = ClientBindingEpoch::parse(&wire.connection_epoch)?; + if connection_epoch != *expected_connection_epoch { + return Err(ClientBindingBootstrapError::ConnectionEpochMismatch); + } + if wire.issued_at == 0 { + return Err(ClientBindingBootstrapError::InvalidIssueTime); + } + if event.created_at.as_secs() != wire.issued_at { + return Err(ClientBindingBootstrapError::EventTimeMismatch); + } + if wire.issued_at > now { + return Err(ClientBindingBootstrapError::NotYetValid); + } + Ok(ClientBindingBootstrapV1 { + authorization_domain: CommunityId::from_uuid(authorization_domain), + event_author_pubkey, + connection_epoch, + issued_at: wire.issued_at, + }) +} + +fn parse_canonical_pubkey(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ClientBindingBootstrapError::InvalidEventAuthorPubkey); + } + PublicKey::from_hex(value).map_err(|_| ClientBindingBootstrapError::InvalidEventAuthorPubkey) +} + +/// Fail-closed bootstrap validation failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ClientBindingBootstrapError { + /// Event kind was not the dedicated bootstrap kind. + #[error("client binding bootstrap event has the wrong kind")] + WrongKind, + /// Payload exceeded the public bound. + #[error("client binding bootstrap payload is too large")] + PayloadTooLarge, + /// Event signature or identifier was invalid. + #[error("client binding bootstrap event is not authenticated")] + UnauthenticatedEvent, + /// Signer did not match NIP-11 `self`. + #[error("client binding bootstrap signer is not the trusted relay")] + UnexpectedRelay, + /// Bootstrap events must have no tags. + #[error("client binding bootstrap contains unexpected tags")] + UnexpectedTags, + /// Payload was not the bounded v1 shape. + #[error("client binding bootstrap payload is malformed")] + MalformedPayload, + /// Wire version is unsupported. + #[error("client binding bootstrap version is unsupported")] + UnsupportedVersion, + /// Authorization domain was nil or noncanonical. + #[error("client binding bootstrap authorization domain is invalid")] + InvalidAuthorizationDomain, + /// Event-author key was noncanonical. + #[error("client binding bootstrap event author is invalid")] + InvalidEventAuthorPubkey, + /// Authenticated author did not match native signing state. + #[error("client binding bootstrap event author does not match")] + EventAuthorMismatch, + /// Connection epoch was noncanonical. + #[error("client binding bootstrap connection epoch is invalid")] + InvalidConnectionEpoch, + /// Echoed epoch did not match the native upgrade header. + #[error("client binding bootstrap connection epoch does not match")] + ConnectionEpochMismatch, + /// Issue time was zero. + #[error("client binding bootstrap issue time is invalid")] + InvalidIssueTime, + /// Signed timestamp did not equal the payload timestamp. + #[error("client binding bootstrap event time does not match")] + EventTimeMismatch, + /// Bootstrap claims a future issue time. + #[error("client binding bootstrap is not yet valid")] + NotYetValid, +} + +/// Bootstrap serialization or signing failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientBindingBootstrapBuildError { + /// JSON serialization failed. + #[error("client binding bootstrap serialization failed")] + Serialization, + /// Serialized payload exceeded its public bound. + #[error("client binding bootstrap payload is too large")] + PayloadTooLarge, + /// Relay signing failed. + #[error("client binding bootstrap signing failed")] + Signing, +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 495fe84654..5d96ad3db2 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -91,6 +91,8 @@ pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; /// is intentionally absent from relay ingest and storage allowlists until the /// binding lifecycle and client-presentation joins are complete. pub const KIND_CLIENT_BINDING_STATUS: u32 = 24244; +/// Buzz relay-authenticated connection binding bootstrap (ephemeral, not stored). +pub const KIND_CLIENT_BINDING_BOOTSTRAP: u32 = 24245; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; @@ -694,6 +696,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, KIND_BLOSSOM_AUTH, + KIND_CLIENT_BINDING_BOOTSTRAP, KIND_PAIRING, KIND_AGENT_OBSERVER_FRAME, KIND_HTTP_AUTH, @@ -829,6 +832,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { matches!( kind, KIND_NIP43_MEMBERSHIP_LIST + | KIND_CLIENT_BINDING_BOOTSTRAP | KIND_CLIENT_BINDING_STATUS | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 6be3e97c40..c80eea91c1 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Relay-authenticated, connection-scoped client binding bootstrap contract. +pub mod client_binding_bootstrap; /// Relay-authenticated, display-only client binding status contract. pub mod client_binding_status; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs index a3e702aeb3..a2ccb9a130 100644 --- a/crates/buzz-relay/src/authorization_runtime/status.rs +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -18,6 +18,7 @@ use buzz_auth::{ AuthorizationProfileId, BindingVersion, PolicyVersion, VerificationOnlyDisposition, }; use buzz_core::{ + client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID, client_binding_status::{ ClientBindingStatusBuildError, ClientBindingStatusError, ClientBindingStatusInputV1, MAX_CLIENT_BINDING_STATUS_LABEL_BYTES, @@ -760,10 +761,8 @@ impl DedicatedClientStatusTransport for ConnectionManagerClientStatusTransport { { return Err(DedicatedClientStatusTransportError::Unavailable); } - let frame = crate::protocol::RelayMessage::event( - "__buzz_client_binding_status_v1__", - delivery.event(), - ); + let frame = + crate::protocol::RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, delivery.event()); self.connections .send_to(delivery.connection_id(), frame) .then_some(()) diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index f9e3081605..1bebb3a6e2 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -16,6 +16,7 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use buzz_auth::{generate_challenge, ConnectionAuthContext, LimitType}; +use buzz_core::client_binding_bootstrap::ClientBindingEpoch; use buzz_core::tenant::TenantContext; use nostr::Filter; @@ -285,6 +286,8 @@ pub struct ConnectionState { pub remote_addr: SocketAddr, /// Optional direct identity assertion captured with verified provenance. pub corporate_identity_assertion: Option, + /// Optional native-generated epoch accepted from the WebSocket upgrade. + pub client_binding_epoch: Option, /// Current NIP-42 authentication state. pub auth_state: RwLock, /// Active subscriptions keyed by subscription ID. @@ -383,6 +386,7 @@ pub async fn handle_connection( addr: SocketAddr, tenant: TenantContext, corporate_identity_assertion: Option, + client_binding_epoch: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -405,6 +409,7 @@ pub async fn handle_connection( conn_id, cancel, corporate_identity_assertion, + client_binding_epoch, ) }, ) @@ -419,6 +424,7 @@ async fn handle_active_connection( conn_id: Uuid, cancel: CancellationToken, corporate_identity_assertion: Option, + client_binding_epoch: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -443,6 +449,7 @@ async fn handle_active_connection( tenant, remote_addr: addr, corporate_identity_assertion, + client_binding_epoch, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), }), diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 9bc15e9e09..c444c88584 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -15,6 +15,9 @@ use axum::extract::ws::Message as WsMessage; use buzz_auth::{ AuthTransport, VerifiedDelegationOutput, VerifiedEvidenceAdapter, VerifiedNostrProof, }; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, +}; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -447,19 +450,36 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc::clone(&verified_proof), verified_assertion.clone(), ); - if let (Some(runtime), Some(assertion)) = - (state.client_status_runtime().cloned(), verified_assertion) - { - if let Err(error) = runtime - .present_after_auth( - Arc::clone(&state), - verified_proof, - assertion, - conn_id, - conn.cancel.clone(), - ) - .await - { + if let (Some(runtime), Some(assertion), Some(epoch)) = ( + state.client_status_runtime().cloned(), + verified_assertion, + conn.client_binding_epoch.clone(), + ) { + let bootstrap_queued = ClientBindingBootstrapInputV1::new( + conn.tenant.community(), + pubkey, + epoch, + nostr::Timestamp::now().as_secs(), + ) + .ok() + .and_then(|input| input.sign_with_relay_keys(&state.relay_keypair).ok()) + .is_some_and(|event| { + conn.send(RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &event)) + }); + let presentation = if bootstrap_queued { + runtime + .present_after_auth( + Arc::clone(&state), + verified_proof, + assertion, + conn_id, + conn.cancel.clone(), + ) + .await + } else { + Err(crate::authorization_runtime::status::ClientStatusRuntimeError::DeliveryUnavailable) + }; + if let Err(error) = presentation { // Presentation failure never widens or narrows access. The // client receives no current indicator and clears any old // status on its existing freshness/disconnect boundary. diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 873f0b21ad..4b956f0a57 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1856,6 +1856,7 @@ mod tests { tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), corporate_identity_assertion: None, + client_binding_epoch: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 5c922b2e52..f7f95ab460 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -27,6 +27,7 @@ use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; use crate::state::AppState; +use buzz_core::client_binding_bootstrap::{ClientBindingEpoch, CLIENT_BINDING_EPOCH_HEADER}; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// @@ -358,6 +359,13 @@ async fn nip11_or_ws_handler( return Json(nip11_document(&state, raw_host).await).into_response(); } + let client_binding_epoch = match client_binding_epoch_from_headers(&headers) { + Ok(epoch) => epoch, + Err(()) => { + return (StatusCode::BAD_REQUEST, "invalid websocket request").into_response(); + } + }; + // Row zero: bind the connection to its community from the request host // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound // connection. The host is the authoritative selector; an unmapped host or a @@ -408,7 +416,14 @@ async fn nip11_or_ws_handler( } limit_relay_websocket(ws, max_frame_bytes) .on_upgrade(move |socket| { - handle_connection(socket, state, addr, tenant, corporate_identity_assertion) + handle_connection( + socket, + state, + addr, + tenant, + corporate_identity_assertion, + client_binding_epoch, + ) }) .into_response() } @@ -430,6 +445,23 @@ async fn nip11_or_ws_handler( } } +fn client_binding_epoch_from_headers( + headers: &HeaderMap, +) -> Result, ()> { + let mut values = headers.get_all(CLIENT_BINDING_EPOCH_HEADER).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(()); + } + let value = value.to_str().map_err(|_| ())?; + if value.contains(',') { + return Err(()); + } + ClientBindingEpoch::parse(value).map(Some).map_err(|_| ()) +} + fn limit_relay_websocket( ws: WebSocketUpgrade, max_frame_bytes: usize, diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 55658369be..2ea0d2af1b 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -2014,6 +2014,7 @@ mod tests { ), remote_addr: "127.0.0.1:1234".parse().unwrap(), corporate_identity_assertion: None, + client_binding_epoch: None, auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), From 1b4bdab5af24946b091f0229144123fca59d74db Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:30:29 -0500 Subject: [PATCH 02/40] feat(desktop): project relay binding status natively Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src-tauri/build.rs | 8 +- .../src/client_binding_status_session.rs | 241 +++++++++++ desktop/src-tauri/src/commands/identity.rs | 14 +- desktop/src-tauri/src/commands/workspace.rs | 3 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/native_websocket.rs | 395 +++++++++++++++++- 6 files changed, 651 insertions(+), 11 deletions(-) create mode 100644 desktop/src-tauri/src/client_binding_status_session.rs diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2b997af891..feba2cea1b 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -112,7 +112,13 @@ fn main() { tauri_build::Attributes::new().plugin( "websocket", tauri_build::InlinedPlugin::new() - .commands(&["connect", "send", "disconnect", "disconnect_all"]) + .commands(&[ + "connect", + "send", + "disconnect", + "disconnect_all", + "current_projection", + ]) .default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands), ), ) diff --git a/desktop/src-tauri/src/client_binding_status_session.rs b/desktop/src-tauri/src/client_binding_status_session.rs new file mode 100644 index 0000000000..5cf2a777d8 --- /dev/null +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -0,0 +1,241 @@ +//! Native-only relay binding status validation and projection. + +use std::fmt; + +use buzz_core_pkg::{ + client_binding_bootstrap::{ + validate_client_binding_bootstrap_event, ClientBindingEpoch, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, + }, + client_binding_status::{ClientBindingStatusTracker, ClientBindingStatusUpdate}, + verify_event, +}; +use nostr::{Event, EventId, PublicKey}; +use serde::Serialize; +use serde_json::Value; + +/// Current-only data permitted to cross the native IPC boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CurrentProjection { + pub(crate) event_author_pubkey: String, + pub(crate) fresh_until: u64, + pub(crate) connection_epoch: String, +} + +/// One change produced by the serialized native fold. +pub(crate) enum ProjectionUpdate { + Unchanged, + Clear, + Current(CurrentProjection), +} + +struct ReservedEvent { + event: Result, + exact_outer_shape: bool, +} + +enum ReservedFrame { + Bootstrap(ReservedEvent), + Status(ReservedEvent), +} + +/// Connection-scoped wrapper around the shared authenticated status tracker. +pub(crate) struct ClientBindingStatusSession { + trusted_relay_pubkey: PublicKey, + expected_event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + bootstrap_event_id: Option, + bootstrap_latched_invalid: bool, + tracker: Option, + projected_fresh_until: Option, +} + +impl fmt::Debug for ClientBindingStatusSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusSession") + .field("trusted_relay_pubkey", &"[redacted]") + .field("expected_event_author_pubkey", &"[redacted]") + .field("connection_epoch", &"[redacted]") + .field( + "bootstrap_event_id", + &self.bootstrap_event_id.map(|_| "[redacted]"), + ) + .field("bootstrap_latched_invalid", &self.bootstrap_latched_invalid) + .field("tracker", &self.tracker.as_ref().map(|_| "[redacted]")) + .field("projected_fresh_until", &"[redacted]") + .finish() + } +} + +impl ClientBindingStatusSession { + pub(crate) fn new( + trusted_relay_pubkey: PublicKey, + expected_event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + ) -> Self { + Self { + trusted_relay_pubkey, + expected_event_author_pubkey, + connection_epoch, + bootstrap_event_id: None, + bootstrap_latched_invalid: false, + tracker: None, + projected_fresh_until: None, + } + } + + pub(crate) fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.connection_epoch + } + + /// Swallow and fold an exact reserved EVENT frame. All other frames return + /// `None` and must be delivered to the webview unchanged. + pub(crate) fn consume_text(&mut self, text: &str, now: u64) -> Option { + let frame = reserved_frame(text.as_bytes())?; + Some(match frame { + ReservedFrame::Bootstrap(event) => self.accept_bootstrap(event, now), + ReservedFrame::Status(event) => self.accept_status(event, now), + }) + } + + pub(crate) fn projected_fresh_until(&self) -> Option { + self.projected_fresh_until + } + + pub(crate) fn expire(&mut self, now: u64) -> ProjectionUpdate { + let expired = self + .projected_fresh_until + .is_some_and(|fresh_until| now >= fresh_until); + if !expired { + return ProjectionUpdate::Unchanged; + } + if let Some(tracker) = self.tracker.as_mut() { + let _ = tracker.current_presentation(now); + } + self.projected_fresh_until = None; + ProjectionUpdate::Clear + } + + pub(crate) fn disconnect(&mut self) -> ProjectionUpdate { + if let Some(tracker) = self.tracker.as_mut() { + tracker.on_disconnect(); + } + self.projected_fresh_until = None; + ProjectionUpdate::Clear + } + + fn accept_bootstrap(&mut self, reserved: ReservedEvent, now: u64) -> ProjectionUpdate { + let Ok(event) = reserved.event else { + return ProjectionUpdate::Unchanged; + }; + if verify_event(&event).is_err() || event.pubkey != self.trusted_relay_pubkey { + return ProjectionUpdate::Unchanged; + } + if !reserved.exact_outer_shape || self.bootstrap_latched_invalid { + self.bootstrap_latched_invalid = true; + return self.clear_trusted_invalid(); + } + let bootstrap = match validate_client_binding_bootstrap_event( + &event, + &self.trusted_relay_pubkey, + &self.connection_epoch, + &self.expected_event_author_pubkey, + now, + ) { + Ok(bootstrap) => bootstrap, + Err(_) => { + self.bootstrap_latched_invalid = true; + return self.clear_trusted_invalid(); + } + }; + if let Some(event_id) = self.bootstrap_event_id { + return if event_id == event.id { + ProjectionUpdate::Unchanged + } else { + self.bootstrap_latched_invalid = true; + self.clear_trusted_invalid() + }; + } + self.bootstrap_event_id = Some(event.id); + self.tracker = Some(ClientBindingStatusTracker::new( + self.trusted_relay_pubkey, + bootstrap.authorization_domain(), + self.expected_event_author_pubkey, + )); + ProjectionUpdate::Unchanged + } + + fn accept_status(&mut self, reserved: ReservedEvent, now: u64) -> ProjectionUpdate { + let Ok(event) = reserved.event else { + return ProjectionUpdate::Unchanged; + }; + if verify_event(&event).is_err() || event.pubkey != self.trusted_relay_pubkey { + return ProjectionUpdate::Unchanged; + } + if self.bootstrap_latched_invalid { + return self.clear_trusted_invalid(); + } + if !reserved.exact_outer_shape { + if self.tracker.is_none() { + self.bootstrap_latched_invalid = true; + } + return self.clear_trusted_invalid(); + } + let Some(tracker) = self.tracker.as_mut() else { + self.bootstrap_latched_invalid = true; + return self.clear_trusted_invalid(); + }; + match tracker.accept(&event, now) { + Ok(ClientBindingStatusUpdate::Duplicate) => ProjectionUpdate::Unchanged, + Ok(ClientBindingStatusUpdate::Accepted) => { + let Some(status) = tracker.current_presentation(now) else { + self.projected_fresh_until = None; + return ProjectionUpdate::Clear; + }; + self.projected_fresh_until = Some(status.fresh_until()); + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: self.expected_event_author_pubkey.to_hex(), + fresh_until: status.fresh_until(), + connection_epoch: self.connection_epoch.as_str().to_owned(), + }) + } + Err(_) => self.clear_trusted_invalid(), + } + } + + fn clear_trusted_invalid(&mut self) -> ProjectionUpdate { + self.projected_fresh_until = None; + ProjectionUpdate::Clear + } +} + +fn reserved_frame(bytes: &[u8]) -> Option { + let value: Value = serde_json::from_slice(bytes).ok()?; + let values = value.as_array()?; + if values.first().and_then(Value::as_str) != Some("EVENT") { + return None; + } + let reserved = match values.get(1).and_then(Value::as_str) { + Some(CLIENT_BINDING_BOOTSTRAP_SUB_ID) => ReservedFrame::Bootstrap, + Some(CLIENT_BINDING_STATUS_SUB_ID) => ReservedFrame::Status, + _ => return None, + }; + let event = values + .get(2) + .cloned() + .ok_or(()) + .and_then(|value| serde_json::from_value(value).map_err(|_| ())); + Some(reserved(ReservedEvent { + event, + exact_outer_shape: values.len() == 3, + })) +} + +/// Classify the reserved exact-connection channels without requiring an +/// eligible presentation session. Every native socket uses this to prevent +/// bootstrap and status frames from reaching raw browser delivery. +pub(crate) fn is_reserved_text(text: &str) -> bool { + reserved_frame(text.as_bytes()).is_some() +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bddf2e725a..83d810e8aa 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -339,7 +339,12 @@ pub async fn import_identity( password: Option, app_handle: tauri::AppHandle, ) -> Result { - tokio::task::spawn_blocking(move || { + let projection_app = app_handle.clone(); + projection_app + .state::() + .invalidate_projection() + .await; + let identity = tokio::task::spawn_blocking(move || { // NIP-49 backups require a passphrase and decrypt entirely in Rust. // Raw nsec/hex input follows the existing parser path unchanged. let password = password.map(zeroize::Zeroizing::new); @@ -385,7 +390,8 @@ pub async fn import_identity( }) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + Ok(identity) } /// Commit an imported identity: durably persist, swap in-memory keys, clear @@ -542,6 +548,10 @@ pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> { ); } + app.state::() + .invalidate_projection() + .await; + // Stop all managed agents before restart so they don't race the wipe. if let Err(e) = crate::shutdown::shutdown_managed_agents(&app) { eprintln!("buzz-desktop sign-out: agent shutdown: {e}"); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..83409d51e6 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -132,6 +132,9 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); + app.state::() + .invalidate_projection() + .await; tokio::task::spawn_blocking(move || { let state = app.state::(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e73b15232..6c64e13342 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod app_menu; mod app_state; mod archive; mod builderlab; +mod client_binding_status_session; mod commands; mod deep_link; mod egress_guard; diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 128f2df79d..d331c772d6 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,19 +1,36 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, net::IpAddr, sync::Arc, time::Duration}; use futures_util::{SinkExt, StreamExt}; +use nostr::PublicKey; use serde::{Deserialize, Serialize}; use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio_tungstenite::{ connect_async, - tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}, + tungstenite::{ + client::IntoClientRequest, + http::HeaderValue, + protocol::{frame::coding::CloseCode, CloseFrame, Message}, + }, }; use tokio_util::sync::CancellationToken; +use url::{Host, Url}; + +use buzz_core_pkg::client_binding_bootstrap::{ClientBindingEpoch, CLIENT_BINDING_EPOCH_HEADER}; + +use crate::{ + app_state::AppState, + client_binding_status_session::{ + is_reserved_text, ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, + }, +}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const WRITE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); const SEND_QUEUE_CAPACITY: usize = 64; +const NIP11_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_NIP11_BODY_BYTES: usize = 64 * 1024; pub(crate) fn install_crypto_provider() { // Dependencies enable both rustls providers; choose one before TLS setup. @@ -81,10 +98,24 @@ struct ConnectionHandle { task: Mutex>>, } +struct ProjectionOwner { + id: Id, + epoch: ClientBindingEpoch, + channel: Channel, +} + +#[derive(Default)] +struct ProjectionState { + generation: u64, + owner: Option, + current: Option, +} + #[derive(Clone)] pub(crate) struct WebSocketManager { connections: Arc>>>, connect_cancel: Arc>, + projection: Arc>, } impl Default for WebSocketManager { @@ -92,6 +123,7 @@ impl Default for WebSocketManager { Self { connections: Arc::default(), connect_cancel: Arc::new(Mutex::new(CancellationToken::new())), + projection: Arc::default(), } } } @@ -101,6 +133,114 @@ impl WebSocketManager { self.connections.lock().await.remove(&id) } + async fn remove_if_current(&self, id: Id, handle: &Arc) { + let mut connections = self.connections.lock().await; + if connections + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, handle)) + { + connections.remove(&id); + } + } + + async fn begin_projection_attempt(&self) -> u64 { + let mut projection = self.projection.lock().await; + projection.generation = projection.generation.wrapping_add(1); + if let Some(previous) = projection.owner.take() { + let _ = previous.channel.send(serde_json::Value::Null); + } + projection.current = None; + projection.generation + } + + async fn activate_projection( + &self, + generation: u64, + id: Id, + epoch: ClientBindingEpoch, + channel: Channel, + ) -> bool { + let mut projection = self.projection.lock().await; + if projection.generation != generation { + let _ = channel.send(serde_json::Value::Null); + return false; + } + projection.current = None; + let _ = channel.send(serde_json::Value::Null); + projection.owner = Some(ProjectionOwner { id, epoch, channel }); + true + } + + async fn apply_projection_update( + &self, + id: Id, + epoch: &ClientBindingEpoch, + update: ProjectionUpdate, + ) { + if matches!(update, ProjectionUpdate::Unchanged) { + return; + } + let mut projection = self.projection.lock().await; + let Some(owner) = projection.owner.as_ref() else { + return; + }; + if owner.id != id || owner.epoch != *epoch { + return; + } + projection.current = match update { + ProjectionUpdate::Current(current) => Some(current), + ProjectionUpdate::Clear | ProjectionUpdate::Unchanged => None, + }; + let value = projection + .current + .as_ref() + .and_then(|current| serde_json::to_value(current).ok()) + .unwrap_or(serde_json::Value::Null); + if let Some(owner) = projection.owner.as_ref() { + let _ = owner.channel.send(value); + } + } + + async fn clear_projection_if_owner(&self, id: Id, epoch: &ClientBindingEpoch) { + let mut projection = self.projection.lock().await; + if projection + .owner + .as_ref() + .is_some_and(|owner| owner.id == id && owner.epoch == *epoch) + { + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + } + + /// Invalidate all browser-visible status and revoke the current socket's + /// ownership. Late work from that socket is rejected by the owner fence. + pub(crate) async fn invalidate_projection(&self) { + let mut projection = self.projection.lock().await; + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + + async fn current_projection(&self) -> Option { + let mut projection = self.projection.lock().await; + if projection + .current + .as_ref() + .is_some_and(|current| unix_now() >= current.fresh_until) + { + projection.current = None; + if let Some(owner) = projection.owner.as_ref() { + let _ = owner.channel.send(serde_json::Value::Null); + } + } + projection.current.clone() + } + async fn disconnect_handle(handle: Arc) { handle.cancel.cancel(); if let Some(mut task) = handle.task.lock().await.take() { @@ -116,20 +256,53 @@ impl WebSocketManager { async fn disconnect(&self, id: Id) { if let Some(handle) = self.remove(id).await { + let owner_epoch = self + .projection + .lock() + .await + .owner + .as_ref() + .filter(|owner| owner.id == id) + .map(|owner| owner.epoch.clone()); + if let Some(epoch) = owner_epoch { + self.clear_projection_if_owner(id, &epoch).await; + } Self::disconnect_handle(handle).await; } } } +#[cfg(test)] async fn open_connection( manager: &WebSocketManager, url: &str, on_message: Channel, +) -> Result { + open_connection_with_projection(manager, url, on_message, None, None, None).await +} + +async fn open_connection_with_projection( + manager: &WebSocketManager, + url: &str, + on_message: Channel, + mut status_session: Option, + projection_channel: Option>, + projection_generation: Option, ) -> Result { let connect_cancel = manager.connect_cancel.lock().await.clone(); + let mut request = url + .into_client_request() + .map_err(|error| error.to_string())?; + if let Some(session) = status_session.as_ref() { + request.headers_mut().insert( + CLIENT_BINDING_EPOCH_HEADER, + HeaderValue::from_str(session.connection_epoch().as_str()) + .map_err(|_| "invalid WebSocket request".to_string())?, + ); + } let (socket, _) = tokio::select! { _ = 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())?, }; @@ -157,14 +330,32 @@ async fn open_connection( let mut task_slot = handle.task.lock().await; manager.connections.lock().await.insert(id, handle.clone()); + let activated = match ( + status_session.as_ref(), + projection_channel.clone(), + projection_generation, + ) { + (Some(session), Some(channel), Some(generation)) => { + manager + .activate_projection(generation, id, session.connection_epoch().clone(), channel) + .await + } + _ => false, + }; + if !activated { + status_session = None; + } + let task_manager = manager.clone(); - let task = tauri::async_runtime::spawn(run_connection( + let task = tauri::async_runtime::spawn(run_connection_inner( id, socket, receiver, cancel, on_message, task_manager, + handle.clone(), + status_session, )); *task_slot = Some(task); drop(task_slot); @@ -175,11 +366,121 @@ async fn open_connection( #[tauri::command] async fn connect( manager: tauri::State<'_, WebSocketManager>, + state: tauri::State<'_, AppState>, url: String, on_message: Channel, + on_projection: Option>, _config: Option, ) -> Result { - open_connection(manager.inner(), &url, on_message).await + let projection_generation = match on_projection.as_ref() { + Some(_) => Some(manager.begin_projection_attempt().await), + None => None, + }; + if let Some(channel) = on_projection.as_ref() { + let _ = channel.send(serde_json::Value::Null); + } + let status_session = match on_projection.as_ref() { + Some(_) => prepare_status_session(&state, &url).await, + None => None, + }; + open_connection_with_projection( + manager.inner(), + &url, + on_message, + status_session, + on_projection, + projection_generation, + ) + .await +} + +async fn prepare_status_session( + state: &AppState, + requested_url: &str, +) -> Option { + if requested_url != crate::relay::relay_ws_url_with_override(state) { + return None; + } + let expected_author = state.signing_keys().ok()?.public_key(); + let relay_signer = fetch_nip11_signer(requested_url).await.ok()?; + let mut epoch_bytes = [0_u8; 32]; + getrandom::getrandom(&mut epoch_bytes).ok()?; + Some(ClientBindingStatusSession::new( + relay_signer, + expected_author, + ClientBindingEpoch::from_random_bytes(epoch_bytes), + )) +} + +#[derive(Deserialize)] +struct Nip11Identity { + #[serde(rename = "self")] + relay_self: String, +} + +async fn fetch_nip11_signer(relay_url: &str) -> Result { + let url = nip11_url(relay_url)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(NIP11_TIMEOUT) + .build() + .map_err(|_| "NIP-11 unavailable".to_string())?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + .map_err(|_| "NIP-11 unavailable".to_string())?; + if !response.status().is_success() + || response + .content_length() + .is_some_and(|length| length > MAX_NIP11_BODY_BYTES as u64) + { + return Err("NIP-11 unavailable".to_string()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "NIP-11 unavailable".to_string())?; + if body.len().saturating_add(chunk.len()) > MAX_NIP11_BODY_BYTES { + return Err("NIP-11 unavailable".to_string()); + } + body.extend_from_slice(&chunk); + } + let identity: Nip11Identity = + serde_json::from_slice(&body).map_err(|_| "NIP-11 unavailable".to_string())?; + if identity.relay_self.len() != 64 + || !identity + .relay_self + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("NIP-11 unavailable".to_string()); + } + PublicKey::from_hex(&identity.relay_self).map_err(|_| "NIP-11 unavailable".to_string()) +} + +fn nip11_url(relay_url: &str) -> Result { + let mut url = Url::parse(relay_url).map_err(|_| "NIP-11 unavailable".to_string())?; + match url.scheme() { + "wss" => url + .set_scheme("https") + .map_err(|_| "NIP-11 unavailable".to_string())?, + "ws" if is_loopback_url(&url) => url + .set_scheme("http") + .map_err(|_| "NIP-11 unavailable".to_string())?, + _ => return Err("NIP-11 unavailable".to_string()), + } + Ok(url) +} + +fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(), + Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(), + None => false, + } } pub(crate) async fn send_message( @@ -241,6 +542,7 @@ async fn disconnect(manager: tauri::State<'_, WebSocketManager>, id: Id) -> Resu #[tauri::command] async fn disconnect_all(manager: tauri::State<'_, WebSocketManager>) -> Result<(), String> { + manager.invalidate_projection().await; let mut connect_cancel = manager.connect_cancel.lock().await; connect_cancel.cancel(); *connect_cancel = CancellationToken::new(); @@ -256,18 +558,58 @@ async fn disconnect_all(manager: tauri::State<'_, WebSocketManager>) -> Result<( Ok(()) } +#[cfg(test)] async fn run_connection( + id: Id, + socket: tokio_tungstenite::WebSocketStream, + receiver: mpsc::Receiver, + cancel: CancellationToken, + on_message: Channel, + manager: WebSocketManager, +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let Some(handle) = manager.connections.lock().await.get(&id).cloned() else { + return; + }; + run_connection_inner( + id, socket, receiver, cancel, on_message, manager, handle, None, + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn run_connection_inner( id: Id, mut socket: tokio_tungstenite::WebSocketStream, mut receiver: mpsc::Receiver, cancel: CancellationToken, on_message: Channel, manager: WebSocketManager, + handle: Arc, + mut status_session: Option, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { loop { + let expiry_delay = status_session + .as_ref() + .and_then(ClientBindingStatusSession::projected_fresh_until) + .map(|fresh_until| Duration::from_secs(fresh_until.saturating_sub(unix_now()))); + let expiry = async { + match expiry_delay { + Some(delay) => tokio::time::sleep(delay).await, + None => std::future::pending::<()>().await, + } + }; tokio::select! { + _ = expiry => { + if let Some(session) = status_session.as_mut() { + let epoch = session.connection_epoch().clone(); + let update = session.expire(unix_now()); + manager.apply_projection_update(id, &epoch, update).await; + } + } _ = cancel.cancelled() => { let _ = tokio::time::timeout( SHUTDOWN_TIMEOUT, @@ -290,7 +632,24 @@ async fn run_connection( } incoming = socket.next() => { let message = match incoming { - Some(Ok(message)) => outbound_message(message), + Some(Ok(message)) => { + let reserved_text = match &message { + Message::Text(value) => Some(value.as_str()), + Message::Binary(value) => std::str::from_utf8(value).ok(), + _ => None, + } + .filter(|text| is_reserved_text(text)); + if let Some(text) = reserved_text { + if let Some(session) = status_session.as_mut() { + let epoch = session.connection_epoch().clone(); + if let Some(update) = session.consume_text(text, unix_now()) { + manager.apply_projection_update(id, &epoch, update).await; + } + } + continue; + } + outbound_message(message) + } Some(Err(error)) => OutboundMessage::Error(error.to_string()), None => OutboundMessage::Close(None), }; @@ -302,7 +661,19 @@ async fn run_connection( } } } - manager.remove(id).await; + if let Some(session) = status_session.as_mut() { + let epoch = session.connection_epoch().clone(); + let update = session.disconnect(); + manager.apply_projection_update(id, &epoch, update).await; + manager.clear_projection_if_owner(id, &epoch).await; + } + manager.remove_if_current(id, &handle).await; +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) } fn outbound_message(message: Message) -> OutboundMessage { @@ -326,7 +697,8 @@ pub fn init() -> TauriPlugin { connect, send, disconnect, - disconnect_all + disconnect_all, + current_projection ]) .setup(|app, _api| { app.manage(WebSocketManager::default()); @@ -335,6 +707,13 @@ pub fn init() -> TauriPlugin { .build() } +#[tauri::command] +async fn current_projection( + manager: tauri::State<'_, WebSocketManager>, +) -> Option { + manager.current_projection().await +} + #[cfg(test)] mod tests { use super::*; From e4cecfd3b777ea0cf839acfcf09e0b13f317d80c Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:32:18 -0500 Subject: [PATCH 03/40] fix(desktop): retain malformed-frame status high-water Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src-tauri/src/client_binding_status_session.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/client_binding_status_session.rs b/desktop/src-tauri/src/client_binding_status_session.rs index 5cf2a777d8..c8309c5df2 100644 --- a/desktop/src-tauri/src/client_binding_status_session.rs +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -178,7 +178,13 @@ impl ClientBindingStatusSession { return self.clear_trusted_invalid(); } if !reserved.exact_outer_shape { - if self.tracker.is_none() { + if let Some(tracker) = self.tracker.as_mut() { + // The outer frame is trusted-invalid, but a valid inner status + // must still consume its revision so replaying the identical + // event later in an exact array cannot restore presentation. + let _ = tracker.accept(&event, now); + tracker.on_disconnect(); + } else { self.bootstrap_latched_invalid = true; } return self.clear_trusted_invalid(); From c6d3ebdfb8414982302d5a75140ce63aa493eabb Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:40:46 -0500 Subject: [PATCH 04/40] test(binding): cover authenticated native status seam Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../buzz-core/src/client_binding_bootstrap.rs | 187 ++++++++++ crates/buzz-core/src/kind.rs | 4 +- crates/buzz-relay/src/router.rs | 33 ++ .../tests/nip_fi_runtime_conformance.rs | 43 ++- .../src/client_binding_status_session.rs | 350 ++++++++++++++++++ desktop/src-tauri/src/native_websocket.rs | 208 ++++++++++- 6 files changed, 822 insertions(+), 3 deletions(-) diff --git a/crates/buzz-core/src/client_binding_bootstrap.rs b/crates/buzz-core/src/client_binding_bootstrap.rs index 1164cbe666..506c8cca4f 100644 --- a/crates/buzz-core/src/client_binding_bootstrap.rs +++ b/crates/buzz-core/src/client_binding_bootstrap.rs @@ -322,3 +322,190 @@ pub enum ClientBindingBootstrapBuildError { #[error("client binding bootstrap signing failed")] Signing, } + +#[cfg(test)] +mod tests { + use super::*; + use nostr::JsonUtil; + use serde_json::{json, Value}; + + const DOMAIN: &str = "abcdefab-cdef-4abc-8def-abcdefabcdef"; + const ISSUED_AT: u64 = 1_800_000_000; + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::parse_str(DOMAIN).expect("synthetic domain is valid")) + } + + fn epoch(byte: u8) -> ClientBindingEpoch { + ClientBindingEpoch::from_random_bytes([byte; 32]) + } + + fn signed_bootstrap(relay: &Keys, author: PublicKey) -> Event { + ClientBindingBootstrapInputV1::new(domain(), author, epoch(0x11), ISSUED_AT) + .expect("synthetic bootstrap input is valid") + .sign_with_relay_keys(relay) + .expect("synthetic bootstrap signs") + } + + fn validate( + event: &Event, + relay: &Keys, + author: &Keys, + expected_epoch: &ClientBindingEpoch, + now: u64, + ) -> Result { + validate_client_binding_bootstrap_event( + event, + &relay.public_key(), + expected_epoch, + &author.public_key(), + now, + ) + } + + fn resign_payload(relay: &Keys, payload: Value) -> Event { + EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_BOOTSTRAP as u16), + payload.to_string(), + ) + .tags([]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(relay) + .expect("synthetic bootstrap variant signs") + } + + #[test] + fn relay_authenticated_bootstrap_roundtrips_exact_authority() { + let relay = Keys::generate(); + let author = Keys::generate(); + let expected_epoch = epoch(0x11); + let event = signed_bootstrap(&relay, author.public_key()); + + let bootstrap = validate(&event, &relay, &author, &expected_epoch, ISSUED_AT) + .expect("synthetic bootstrap validates"); + + assert_eq!(event.kind.as_u16() as u32, KIND_CLIENT_BINDING_BOOTSTRAP); + assert!(event.tags.is_empty()); + assert_eq!(bootstrap.authorization_domain(), domain()); + assert_eq!(bootstrap.event_author_pubkey(), author.public_key()); + assert_eq!(bootstrap.connection_epoch(), &expected_epoch); + assert_eq!(bootstrap.issued_at(), ISSUED_AT); + let payload: Value = serde_json::from_str(&event.content).expect("payload parses"); + assert_eq!( + payload + .as_object() + .expect("payload is an object") + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from([ + "authorization_domain", + "connection_epoch", + "event_author_pubkey", + "issued_at", + "version", + ]) + ); + let debug = format!("{bootstrap:?}"); + assert!(!debug.contains(DOMAIN)); + assert!(!debug.contains(&author.public_key().to_hex())); + assert!(!debug.contains(expected_epoch.as_str())); + } + + #[test] + fn bootstrap_rejects_noncanonical_epoch_and_invalid_input_bounds() { + assert_eq!( + ClientBindingEpoch::parse(&"A".repeat(64)), + Err(ClientBindingBootstrapError::InvalidConnectionEpoch) + ); + assert_eq!( + ClientBindingEpoch::parse(&"a".repeat(63)), + Err(ClientBindingBootstrapError::InvalidConnectionEpoch) + ); + assert_eq!( + ClientBindingBootstrapInputV1::new( + CommunityId::from_uuid(Uuid::nil()), + Keys::generate().public_key(), + epoch(1), + ISSUED_AT, + ) + .expect_err("nil domains are invalid"), + ClientBindingBootstrapError::InvalidAuthorizationDomain + ); + assert_eq!( + ClientBindingBootstrapInputV1::new( + domain(), + Keys::generate().public_key(), + epoch(1), + 0, + ) + .expect_err("zero issue time is invalid"), + ClientBindingBootstrapError::InvalidIssueTime + ); + } + + #[test] + fn bootstrap_rejects_wrong_scope_tampering_and_unknown_shape() { + let relay = Keys::generate(); + let wrong_relay = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let expected_epoch = epoch(0x11); + let event = signed_bootstrap(&relay, author.public_key()); + + assert_eq!( + validate(&event, &wrong_relay, &author, &expected_epoch, ISSUED_AT), + Err(ClientBindingBootstrapError::UnexpectedRelay) + ); + assert_eq!( + validate(&event, &relay, &other_author, &expected_epoch, ISSUED_AT), + Err(ClientBindingBootstrapError::EventAuthorMismatch) + ); + assert_eq!( + validate(&event, &relay, &author, &epoch(0x22), ISSUED_AT), + Err(ClientBindingBootstrapError::ConnectionEpochMismatch) + ); + assert_eq!( + validate(&event, &relay, &author, &expected_epoch, ISSUED_AT - 1), + Err(ClientBindingBootstrapError::NotYetValid) + ); + + let mut tampered_json: Value = + serde_json::from_str(&event.as_json()).expect("event parses"); + tampered_json["content"] = Value::String("{}".to_string()); + let tampered = + Event::from_json(tampered_json.to_string()).expect("tampered event still parses"); + assert_eq!( + validate(&tampered, &relay, &author, &expected_epoch, ISSUED_AT), + Err(ClientBindingBootstrapError::UnauthenticatedEvent) + ); + + let mut payload: Value = + serde_json::from_str(&event.content).expect("bootstrap content parses"); + payload["synthetic_extension"] = json!(true); + assert_eq!( + validate( + &resign_payload(&relay, payload), + &relay, + &author, + &expected_epoch, + ISSUED_AT, + ), + Err(ClientBindingBootstrapError::MalformedPayload) + ); + + let mut payload: Value = + serde_json::from_str(&event.content).expect("bootstrap content parses"); + payload["authorization_domain"] = json!(DOMAIN.to_uppercase()); + assert_eq!( + validate( + &resign_payload(&relay, payload), + &relay, + &author, + &expected_epoch, + ISSUED_AT, + ), + Err(ClientBindingBootstrapError::InvalidAuthorizationDomain) + ); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 5d96ad3db2..3a64ecc5ee 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -916,7 +916,9 @@ mod tests { } #[test] - fn client_binding_status_is_relay_only() { + fn client_binding_connection_events_are_relay_only_and_ephemeral() { + assert!(is_relay_only_kind(KIND_CLIENT_BINDING_BOOTSTRAP)); + assert!(is_ephemeral(KIND_CLIENT_BINDING_BOOTSTRAP)); assert!(is_relay_only_kind(KIND_CLIENT_BINDING_STATUS)); assert!(is_ephemeral(KIND_CLIENT_BINDING_STATUS)); } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index f7f95ab460..6367b11ab3 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -763,4 +763,37 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } + + #[test] + fn client_binding_epoch_header_is_absent_or_one_exact_canonical_value() { + let epoch = "11".repeat(32); + let mut headers = HeaderMap::new(); + assert_eq!(client_binding_epoch_from_headers(&headers), Ok(None)); + + headers.insert( + CLIENT_BINDING_EPOCH_HEADER, + axum::http::HeaderValue::from_str(&epoch).expect("canonical header value"), + ); + assert_eq!( + client_binding_epoch_from_headers(&headers), + Ok(Some( + ClientBindingEpoch::parse(&epoch).expect("canonical epoch") + )) + ); + + for invalid in ["AA".repeat(32), "11".repeat(31), format!("{epoch},x")] { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_BINDING_EPOCH_HEADER, + axum::http::HeaderValue::from_str(&invalid).expect("HTTP-safe test value"), + ); + assert_eq!(client_binding_epoch_from_headers(&headers), Err(())); + } + + let mut duplicated = HeaderMap::new(); + let value = axum::http::HeaderValue::from_str(&epoch).expect("canonical header value"); + duplicated.append(CLIENT_BINDING_EPOCH_HEADER, value.clone()); + duplicated.append(CLIENT_BINDING_EPOCH_HEADER, value); + assert_eq!(client_binding_epoch_from_headers(&duplicated), Err(())); + } } diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs index 95b0d110f5..507b42d98b 100644 --- a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -318,6 +318,22 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { continue; } let source = fs::read_to_string(&file).expect("source file is readable"); + let native_consumer = file + == repo.join("desktop/src-tauri/src/client_binding_status_session.rs") + || file == repo.join("desktop/src-tauri/src/native_websocket.rs"); + let native_module_declaration = file == repo.join("desktop/src-tauri/src/lib.rs"); + if native_module_declaration { + assert_eq!( + source.matches("mod client_binding_status_session;").count(), + 1, + "native status module must have one private declaration" + ); + } + let source_to_scan = if native_module_declaration { + source.replacen("mod client_binding_status_session;", "", 1) + } else { + source.clone() + }; for forbidden in [ "KIND_CLIENT_BINDING_STATUS", "ClientBindingStatus", @@ -325,8 +341,13 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { "24244", "deliver_verification_only", ] { + if native_consumer + && matches!(forbidden, "ClientBindingStatus" | "client_binding_status") + { + continue; + } assert!( - !source.contains(forbidden), + !source_to_scan.contains(forbidden), "{} exposes status through an ordinary route {forbidden}", file.display() ); @@ -355,6 +376,26 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { assert!(!status.contains("std::env")); } +#[test] +fn auth_bootstrap_precedes_status_and_success_ack() { + let bootstrap = AUTH_HANDLER + .find("conn.send(RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &event))") + .expect("AUTH queues the exact-connection bootstrap"); + let presentation = AUTH_HANDLER + .find(".present_after_auth(") + .expect("AUTH invokes O4 presentation"); + let success = AUTH_HANDLER + .find("conn.send(RelayMessage::ok(&event_id_hex, true, \"\"))") + .expect("AUTH queues the successful NIP-42 acknowledgement"); + + assert!( + bootstrap < presentation && presentation < success, + "bootstrap must be queued before O4 status and the NIP-42 OK" + ); + assert!(AUTH_HANDLER.contains("if let (Some(runtime), Some(assertion), Some(epoch)) =")); + assert!(AUTH_HANDLER.contains("let presentation = if bootstrap_queued")); +} + #[test] fn neutral_verified_evidence_is_exactly_one_and_reachable_in_production() { assert!(TRANSPORT_RUNTIME.contains("trait VerifiedProviderEvidenceResolver")); diff --git a/desktop/src-tauri/src/client_binding_status_session.rs b/desktop/src-tauri/src/client_binding_status_session.rs index c8309c5df2..aee4ec22cf 100644 --- a/desktop/src-tauri/src/client_binding_status_session.rs +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -245,3 +245,353 @@ fn reserved_frame(bytes: &[u8]) -> Option { pub(crate) fn is_reserved_text(text: &str) -> bool { reserved_frame(text.as_bytes()).is_some() } + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core_pkg::{ + client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + CLIENT_BINDING_STATUS_SUB_ID, + }, + client_binding_status::{ClientBindingStatusDisposition, ClientBindingStatusInputV1}, + CommunityId, + }; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Timestamp}; + use serde_json::json; + use uuid::Uuid; + + const ISSUED_AT: u64 = 1_800_000_000; + const FRESH_UNTIL: u64 = ISSUED_AT + 120; + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0x1234)) + } + + fn epoch() -> ClientBindingEpoch { + ClientBindingEpoch::from_random_bytes([0x11; 32]) + } + + fn session(relay: &Keys, author: &Keys) -> ClientBindingStatusSession { + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch()) + } + + fn bootstrap(relay: &Keys, author: &Keys) -> Event { + ClientBindingBootstrapInputV1::new(domain(), author.public_key(), epoch(), ISSUED_AT) + .expect("synthetic bootstrap input") + .sign_with_relay_keys(relay) + .expect("synthetic bootstrap signs") + } + + fn status( + relay: &Keys, + author: &Keys, + revision: u64, + disposition: ClientBindingStatusDisposition, + policy: &str, + ) -> Event { + let input = match disposition { + ClientBindingStatusDisposition::DisplayCurrent => ClientBindingStatusInputV1::current( + domain(), + author.public_key(), + 7, + policy, + revision, + ISSUED_AT, + FRESH_UNTIL, + None, + ), + ClientBindingStatusDisposition::Withdrawn => ClientBindingStatusInputV1::withdrawn( + domain(), + author.public_key(), + revision, + ISSUED_AT, + FRESH_UNTIL, + ), + } + .expect("synthetic status input"); + input + .sign_with_relay_keys(relay) + .expect("synthetic status signs") + } + + fn frame(sub_id: &str, event: &Event) -> String { + json!(["EVENT", sub_id, event]).to_string() + } + + fn extra_outer_value_frame(sub_id: &str, event: &Event) -> String { + json!(["EVENT", sub_id, event, "unexpected"]).to_string() + } + + fn assert_unchanged(update: Option) { + assert!(matches!(update, Some(ProjectionUpdate::Unchanged))); + } + + fn assert_clear(update: Option) { + assert!(matches!(update, Some(ProjectionUpdate::Clear))); + } + + fn assert_current(update: Option, author: &Keys, revision: u64) { + let Some(ProjectionUpdate::Current(current)) = update else { + panic!("expected a current projection"); + }; + assert_eq!(current.event_author_pubkey, author.public_key().to_hex()); + assert_eq!(current.fresh_until, FRESH_UNTIL); + assert_eq!(current.connection_epoch, epoch().as_str()); + assert!(revision > 0, "call site documents the accepted revision"); + } + + #[test] + fn reserved_channels_are_classified_before_event_decoding() { + assert!(is_reserved_text( + &json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]).to_string() + )); + assert!(is_reserved_text( + &json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "not-an-event"]).to_string() + )); + assert!(!is_reserved_text( + &json!(["EVENT", "ordinary-subscription", {}]).to_string() + )); + assert!(!is_reserved_text("not-json")); + } + + #[test] + fn status_before_bootstrap_fails_closed_and_latches_the_session() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let current = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + + assert_clear( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + ); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + assert!(session.projected_fresh_until().is_none()); + } + + #[test] + fn unauthenticated_or_wrong_signer_noise_has_no_effect() { + let relay = Keys::generate(); + let wrong_relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let wrong_signer = status( + &wrong_relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &wrong_signer), + ISSUED_AT, + )); + + let signed = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + let mut tampered_json: Value = + serde_json::from_str(&signed.as_json()).expect("status event parses"); + tampered_json["content"] = Value::String("{}".to_string()); + let tampered = Event::from_json(tampered_json.to_string()).expect("tampered event parses"); + assert_unchanged( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &tampered), ISSUED_AT), + ); + + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &signed), ISSUED_AT), + &author, + 1, + ); + } + + #[test] + fn trusted_invalid_status_clears_duplicate_cannot_restore_and_newer_can() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let bootstrap = bootstrap(&relay, &author); + let current = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + let conflicting_equal = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "different-policy", + ); + let newer = status( + &relay, + &author, + 2, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v2", + ); + + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap), + ISSUED_AT, + )); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + &author, + 1, + ); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &conflicting_equal), + ISSUED_AT, + )); + assert_unchanged( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + ); + assert!(session.projected_fresh_until().is_none()); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &newer), ISSUED_AT), + &author, + 2, + ); + } + + #[test] + fn malformed_reserved_outer_shape_consumes_high_water_before_clearing() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let revision_two = status( + &relay, + &author, + 2, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v2", + ); + let revision_three = status( + &relay, + &author, + 3, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v3", + ); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + + assert_clear(session.consume_text( + &extra_outer_value_frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_two), + ISSUED_AT, + )); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_two), + ISSUED_AT, + )); + assert!(session.projected_fresh_until().is_none()); + assert_current( + session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_three), + ISSUED_AT, + ), + &author, + 3, + ); + } + + #[test] + fn withdrawal_and_passive_expiry_clear_current_projection() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + let current = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + &author, + 1, + ); + let withdrawn = status( + &relay, + &author, + 2, + ClientBindingStatusDisposition::Withdrawn, + "unused", + ); + assert_clear( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &withdrawn), ISSUED_AT), + ); + + let newer = status( + &relay, + &author, + 3, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v3", + ); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &newer), ISSUED_AT), + &author, + 3, + ); + assert!(matches!( + session.expire(FRESH_UNTIL - 1), + ProjectionUpdate::Unchanged + )); + assert!(matches!( + session.expire(FRESH_UNTIL), + ProjectionUpdate::Clear + )); + assert!(session.projected_fresh_until().is_none()); + } + + #[test] + fn expected_signer_invalid_bootstrap_latches_exact_origin() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let invalid = EventBuilder::new( + Kind::Custom(buzz_core_pkg::kind::KIND_CLIENT_BINDING_BOOTSTRAP as u16), + "{}", + ) + .tags([]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("trusted-invalid bootstrap signs"); + + assert_clear( + session.consume_text(&frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &invalid), ISSUED_AT), + ); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + } +} diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index d331c772d6..5113e29595 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -718,8 +718,11 @@ async fn current_projection( mod tests { use super::*; use futures_util::FutureExt; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use buzz_core_pkg::client_binding_bootstrap::{ + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, + }; use tauri::ipc::InvokeResponseBody; use tokio::io::duplex; use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; @@ -929,4 +932,207 @@ mod tests { assert!(healthy_receiver.recv().await.is_some()); drop(blocked_receiver); } + + #[test] + fn nip11_url_accepts_tls_or_loopback_only() { + assert_eq!( + nip11_url("wss://relay.example.test/community?view=1") + .expect("secure relay URL is eligible") + .as_str(), + "https://relay.example.test/community?view=1" + ); + assert_eq!( + nip11_url("ws://localhost:3000/") + .expect("localhost relay URL is eligible") + .as_str(), + "http://localhost:3000/" + ); + assert!(nip11_url("ws://127.0.0.1:3000/").is_ok()); + assert!(nip11_url("ws://[::1]:3000/").is_ok()); + assert!(nip11_url("ws://relay.example.test/").is_err()); + assert!(nip11_url("http://localhost:3000/").is_err()); + + assert!(is_loopback_url( + &Url::parse("ws://LOCALHOST:3000/").expect("test URL") + )); + assert!(!is_loopback_url( + &Url::parse("ws://localhost.example.test/").expect("test URL") + )); + } + + #[tokio::test] + async fn projection_generation_and_owner_epoch_fence_late_updates() { + let manager = WebSocketManager::default(); + let stale_generation = manager.begin_projection_attempt().await; + let current_generation = manager.begin_projection_attempt().await; + let stale_epoch = ClientBindingEpoch::from_random_bytes([0x11; 32]); + let current_epoch = ClientBindingEpoch::from_random_bytes([0x22; 32]); + + assert!( + !manager + .activate_projection(stale_generation, 1, stale_epoch.clone(), silent_channel(),) + .await + ); + assert!( + manager + .activate_projection( + current_generation, + 2, + current_epoch.clone(), + silent_channel(), + ) + .await + ); + let current = CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: u64::MAX, + connection_epoch: current_epoch.as_str().to_owned(), + }; + + manager + .apply_projection_update(1, &stale_epoch, ProjectionUpdate::Current(current.clone())) + .await; + assert!(manager.projection.lock().await.current.is_none()); + manager + .apply_projection_update(2, &stale_epoch, ProjectionUpdate::Current(current.clone())) + .await; + assert!(manager.projection.lock().await.current.is_none()); + manager + .apply_projection_update( + 2, + ¤t_epoch, + ProjectionUpdate::Current(current.clone()), + ) + .await; + assert_eq!(manager.projection.lock().await.current, Some(current)); + + let next_generation = manager.begin_projection_attempt().await; + assert!(manager.projection.lock().await.current.is_none()); + assert!( + manager + .activate_projection( + next_generation, + 3, + ClientBindingEpoch::from_random_bytes([0x33; 32]), + silent_channel(), + ) + .await + ); + manager + .apply_projection_update( + 2, + ¤t_epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: "22".repeat(32), + fresh_until: u64::MAX, + connection_epoch: current_epoch.as_str().to_owned(), + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_none()); + } + + #[tokio::test] + async fn stale_task_cannot_remove_reused_connection_id() { + let manager = WebSocketManager::default(); + let (old_sender, _old_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let old = Arc::new(ConnectionHandle { + sender: old_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + }); + let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let current = Arc::new(ConnectionHandle { + sender: new_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + }); + manager.connections.lock().await.insert(9, old.clone()); + manager.connections.lock().await.insert(9, current.clone()); + + manager.remove_if_current(9, &old).await; + assert!(manager + .connections + .lock() + .await + .get(&9) + .is_some_and(|handle| Arc::ptr_eq(handle, ¤t))); + manager.remove_if_current(9, ¤t).await; + assert!(!manager.connections.lock().await.contains_key(&9)); + } + + #[tokio::test] + async fn reserved_frames_are_swallowed_without_an_eligible_session() { + let manager = WebSocketManager::default(); + let delivered = Arc::new(AtomicUsize::new(0)); + let delivered_for_channel = delivered.clone(); + let channel = Channel::new(move |_: InvokeResponseBody| { + delivered_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + }); + manager.connections.lock().await.insert(42, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 42, + client, + receiver, + handle.cancel.clone(), + channel, + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]) + .to_string() + .into(), + )) + .await + .expect("send reserved bootstrap frame"); + server + .send(Message::Binary( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into_bytes() + .into(), + )) + .await + .expect("send reserved status frame"); + server + .send(Message::Text("ordinary".into())) + .await + .expect("send ordinary frame"); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + + let task = handle + .task + .lock() + .await + .take() + .expect("connection task is registered"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + assert_eq!( + delivered.load(Ordering::SeqCst), + 2, + "only the ordinary text and terminal close reach raw delivery" + ); + assert!(!manager.connections.lock().await.contains_key(&42)); + } } From a7bcadfd51d32783d92ed3643bcbe0494e74519a Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:54 -0500 Subject: [PATCH 05/40] fix(binding): authenticate relay connection scope Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../buzz-core/src/client_binding_bootstrap.rs | 174 ++++++++++++++++-- crates/buzz-core/src/client_binding_status.rs | 35 ++++ crates/buzz-relay/src/connection.rs | 7 - crates/buzz-relay/src/handlers/auth.rs | 12 +- crates/buzz-relay/src/handlers/event.rs | 1 - crates/buzz-relay/src/router.rs | 67 +------ crates/buzz-relay/src/state.rs | 1 - 7 files changed, 200 insertions(+), 97 deletions(-) diff --git a/crates/buzz-core/src/client_binding_bootstrap.rs b/crates/buzz-core/src/client_binding_bootstrap.rs index 506c8cca4f..c3a8f999f1 100644 --- a/crates/buzz-core/src/client_binding_bootstrap.rs +++ b/crates/buzz-core/src/client_binding_bootstrap.rs @@ -12,10 +12,13 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; -use crate::{kind::KIND_CLIENT_BINDING_BOOTSTRAP, verify_event, CommunityId}; +use crate::{ + client_binding_status::MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS, + kind::KIND_CLIENT_BINDING_BOOTSTRAP, verify_event, CommunityId, +}; -/// WebSocket upgrade header carrying a native-generated connection epoch. -pub const CLIENT_BINDING_EPOCH_HEADER: &str = "x-buzz-client-binding-epoch-v1"; +/// Integrity-protected NIP-42 tag carrying the native connection scope. +pub const CLIENT_BINDING_SCOPE_TAG: &str = "buzz_client_binding_scope"; /// Reserved exact-connection subscription id for bootstrap delivery. pub const CLIENT_BINDING_BOOTSTRAP_SUB_ID: &str = "__buzz_client_binding_bootstrap_v1__"; /// Reserved exact-connection subscription id for status delivery. @@ -25,34 +28,98 @@ pub const CLIENT_BINDING_BOOTSTRAP_VERSION: u64 = 1; /// Maximum encoded bootstrap payload length. pub const MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES: usize = 1024; -/// Opaque, native-generated 256-bit connection epoch. +/// Opaque, native-generated canonical lowercase UUIDv4 connection epoch. #[derive(Clone, PartialEq, Eq, Hash)] pub struct ClientBindingEpoch(String); impl ClientBindingEpoch { - /// Construct an epoch from 32 CSPRNG bytes. + /// Generate a fresh connection epoch from the operating system CSPRNG. + pub fn new_v4() -> Self { + Self(Uuid::new_v4().to_string()) + } + + /// Construct a canonical UUIDv4 epoch from caller-supplied CSPRNG bytes. pub fn from_random_bytes(bytes: [u8; 32]) -> Self { - Self(hex::encode(bytes)) + let mut uuid_bytes = [0_u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + uuid_bytes[6] = (uuid_bytes[6] & 0x0f) | 0x40; + uuid_bytes[8] = (uuid_bytes[8] & 0x3f) | 0x80; + Self(Uuid::from_bytes(uuid_bytes).to_string()) } - /// Parse the canonical 64-character lowercase hexadecimal wire form. + /// Parse the canonical lowercase hyphenated UUIDv4 wire form. pub fn parse(value: &str) -> Result { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { + let parsed = Uuid::parse_str(value) + .map_err(|_| ClientBindingBootstrapError::InvalidConnectionEpoch)?; + let bytes = parsed.as_bytes(); + if parsed.to_string() != value || (bytes[6] >> 4) != 4 || (bytes[8] >> 6) != 2 { return Err(ClientBindingBootstrapError::InvalidConnectionEpoch); } Ok(Self(value.to_owned())) } - /// Canonical header and payload representation. + /// Canonical payload and signed-tag representation. pub fn as_str(&self) -> &str { &self.0 } } +/// Verified native connection scope carried by one signed NIP-42 AUTH event. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientBindingScopeV1 { + connection_epoch: ClientBindingEpoch, + relay_signer: PublicKey, +} + +impl ClientBindingScopeV1 { + /// Parse exactly one canonical v1 scope tag from a verified AUTH event. + /// + /// This parser does not authenticate the event. Callers must invoke it only + /// after the ordinary NIP-42 signature, challenge, and relay checks pass. + pub fn from_verified_auth_event(event: &Event) -> Result { + let mut matching = event.tags.iter().filter(|tag| { + tag.as_slice().first().map(String::as_str) == Some(CLIENT_BINDING_SCOPE_TAG) + }); + let tag = matching + .next() + .ok_or(ClientBindingBootstrapError::MissingScopeTag)?; + if matching.next().is_some() { + return Err(ClientBindingBootstrapError::DuplicateScopeTag); + } + let values = tag.as_slice(); + if values.len() != 4 || values[1] != "1" { + return Err(ClientBindingBootstrapError::InvalidScopeTag); + } + let connection_epoch = ClientBindingEpoch::parse(&values[2])?; + let relay_signer = parse_canonical_pubkey(&values[3]) + .map_err(|_| ClientBindingBootstrapError::InvalidScopeTag)?; + Ok(Self { + connection_epoch, + relay_signer, + }) + } + + /// Native-generated epoch authenticated by the NIP-42 event signature. + pub fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.connection_epoch + } + + /// NIP-11 relay signer pinned by native before the socket was opened. + pub const fn relay_signer(&self) -> PublicKey { + self.relay_signer + } +} + +impl fmt::Debug for ClientBindingScopeV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingScopeV1") + .field("connection_epoch", &"[redacted]") + .field("relay_signer", &"[redacted]") + .finish() + } +} + impl fmt::Debug for ClientBindingEpoch { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -239,6 +306,9 @@ pub fn validate_client_binding_bootstrap_event( if wire.issued_at > now { return Err(ClientBindingBootstrapError::NotYetValid); } + if now - wire.issued_at > MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS { + return Err(ClientBindingBootstrapError::Expired); + } Ok(ClientBindingBootstrapV1 { authorization_domain: CommunityId::from_uuid(authorization_domain), event_author_pubkey, @@ -262,6 +332,15 @@ fn parse_canonical_pubkey(value: &str) -> Result ClientBindingEpoch { - ClientBindingEpoch::from_random_bytes([byte; 32]) + ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{byte:012x}")) + .expect("synthetic epoch is canonical UUIDv4") } fn signed_bootstrap(relay: &Keys, author: PublicKey) -> Event { @@ -415,11 +498,15 @@ mod tests { #[test] fn bootstrap_rejects_noncanonical_epoch_and_invalid_input_bounds() { assert_eq!( - ClientBindingEpoch::parse(&"A".repeat(64)), + ClientBindingEpoch::parse("11111111-1111-4111-8111-AAAAAAAAAAAA"), + Err(ClientBindingBootstrapError::InvalidConnectionEpoch) + ); + assert_eq!( + ClientBindingEpoch::parse("11111111-1111-5111-8111-111111111111"), Err(ClientBindingBootstrapError::InvalidConnectionEpoch) ); assert_eq!( - ClientBindingEpoch::parse(&"a".repeat(63)), + ClientBindingEpoch::parse("11111111-1111-4111-7111-111111111111"), Err(ClientBindingBootstrapError::InvalidConnectionEpoch) ); assert_eq!( @@ -469,6 +556,16 @@ mod tests { validate(&event, &relay, &author, &expected_epoch, ISSUED_AT - 1), Err(ClientBindingBootstrapError::NotYetValid) ); + assert_eq!( + validate( + &event, + &relay, + &author, + &expected_epoch, + ISSUED_AT + MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS + 1, + ), + Err(ClientBindingBootstrapError::Expired) + ); let mut tampered_json: Value = serde_json::from_str(&event.as_json()).expect("event parses"); @@ -508,4 +605,45 @@ mod tests { Err(ClientBindingBootstrapError::InvalidAuthorizationDomain) ); } + + #[test] + fn verified_auth_scope_requires_one_exact_signed_tag() { + let author = Keys::generate(); + let relay = Keys::generate(); + let epoch = epoch(0x11); + let scope = vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + epoch.as_str().to_string(), + relay.public_key().to_hex(), + ]; + let auth = EventBuilder::new(Kind::Custom(22242), "") + .tags([Tag::parse(scope.clone()).expect("synthetic scope tag")]) + .sign_with_keys(&author) + .expect("synthetic AUTH signs"); + let parsed = ClientBindingScopeV1::from_verified_auth_event(&auth) + .expect("exact signed scope parses"); + assert_eq!(parsed.connection_epoch(), &epoch); + assert_eq!(parsed.relay_signer(), relay.public_key()); + + let missing = EventBuilder::new(Kind::Custom(22242), "") + .sign_with_keys(&author) + .expect("synthetic AUTH signs"); + assert_eq!( + ClientBindingScopeV1::from_verified_auth_event(&missing), + Err(ClientBindingBootstrapError::MissingScopeTag) + ); + + let duplicate = EventBuilder::new(Kind::Custom(22242), "") + .tags([ + Tag::parse(scope.clone()).expect("synthetic scope tag"), + Tag::parse(scope).expect("synthetic scope tag"), + ]) + .sign_with_keys(&author) + .expect("synthetic AUTH signs"); + assert_eq!( + ClientBindingScopeV1::from_verified_auth_event(&duplicate), + Err(ClientBindingBootstrapError::DuplicateScopeTag) + ); + } } diff --git a/crates/buzz-core/src/client_binding_status.rs b/crates/buzz-core/src/client_binding_status.rs index 6428938d23..038992af4d 100644 --- a/crates/buzz-core/src/client_binding_status.rs +++ b/crates/buzz-core/src/client_binding_status.rs @@ -602,6 +602,41 @@ impl ClientBindingStatusTracker { self.status = None; } + /// Clear presentation and retain a parseable trusted-invalid revision. + /// + /// The event is independently authenticated against this tracker's relay + /// before its bounded revision is considered. Retaining the revision + /// prevents replaying the same malformed envelope as a later syntactically + /// valid value from restoring presentation. + pub fn retain_trusted_invalid_high_water(&mut self, event: &Event) { + #[derive(Deserialize)] + struct RevisionOnly { + status_revision: u64, + } + + self.status = None; + if event.content.len() > MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES + || verify_event(event).is_err() + || event.pubkey != self.trusted_relay_pubkey + { + return; + } + let Ok(candidate) = serde_json::from_str::(&event.content) else { + return; + }; + if candidate.status_revision == 0 + || self + .high_water + .is_some_and(|high_water| candidate.status_revision <= high_water.revision) + { + return; + } + self.high_water = Some(StatusHighWater { + revision: candidate.status_revision, + event_id: event.id, + }); + } + /// Replace the trusted scope and clear both presentation and revision state. /// /// Call this on relay-identity, authorization-domain, or event-author diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 1bebb3a6e2..f9e3081605 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -16,7 +16,6 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use buzz_auth::{generate_challenge, ConnectionAuthContext, LimitType}; -use buzz_core::client_binding_bootstrap::ClientBindingEpoch; use buzz_core::tenant::TenantContext; use nostr::Filter; @@ -286,8 +285,6 @@ pub struct ConnectionState { pub remote_addr: SocketAddr, /// Optional direct identity assertion captured with verified provenance. pub corporate_identity_assertion: Option, - /// Optional native-generated epoch accepted from the WebSocket upgrade. - pub client_binding_epoch: Option, /// Current NIP-42 authentication state. pub auth_state: RwLock, /// Active subscriptions keyed by subscription ID. @@ -386,7 +383,6 @@ pub async fn handle_connection( addr: SocketAddr, tenant: TenantContext, corporate_identity_assertion: Option, - client_binding_epoch: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -409,7 +405,6 @@ pub async fn handle_connection( conn_id, cancel, corporate_identity_assertion, - client_binding_epoch, ) }, ) @@ -424,7 +419,6 @@ async fn handle_active_connection( conn_id: Uuid, cancel: CancellationToken, corporate_identity_assertion: Option, - client_binding_epoch: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -449,7 +443,6 @@ async fn handle_active_connection( tenant, remote_addr: addr, corporate_identity_assertion, - client_binding_epoch, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), }), diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index c444c88584..9a46ff9af9 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -16,7 +16,7 @@ use buzz_auth::{ AuthTransport, VerifiedDelegationOutput, VerifiedEvidenceAdapter, VerifiedNostrProof, }; use buzz_core::client_binding_bootstrap::{ - ClientBindingBootstrapInputV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + ClientBindingBootstrapInputV1, ClientBindingScopeV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, }; use tracing::{debug, info, warn}; @@ -444,21 +444,25 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } }; + let client_binding_scope = + ClientBindingScopeV1::from_verified_auth_event(&verified_event) + .ok() + .filter(|scope| scope.relay_signer() == state.relay_keypair.public_key()); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state.conn_manager.set_authenticated_authority( conn_id, Arc::clone(&verified_proof), verified_assertion.clone(), ); - if let (Some(runtime), Some(assertion), Some(epoch)) = ( + if let (Some(runtime), Some(assertion), Some(scope)) = ( state.client_status_runtime().cloned(), verified_assertion, - conn.client_binding_epoch.clone(), + client_binding_scope, ) { let bootstrap_queued = ClientBindingBootstrapInputV1::new( conn.tenant.community(), pubkey, - epoch, + scope.connection_epoch().clone(), nostr::Timestamp::now().as_secs(), ) .ok() diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 4b956f0a57..873f0b21ad 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1856,7 +1856,6 @@ mod tests { tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), corporate_identity_assertion: None, - client_binding_epoch: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 6367b11ab3..5c922b2e52 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -27,7 +27,6 @@ use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; use crate::state::AppState; -use buzz_core::client_binding_bootstrap::{ClientBindingEpoch, CLIENT_BINDING_EPOCH_HEADER}; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// @@ -359,13 +358,6 @@ async fn nip11_or_ws_handler( return Json(nip11_document(&state, raw_host).await).into_response(); } - let client_binding_epoch = match client_binding_epoch_from_headers(&headers) { - Ok(epoch) => epoch, - Err(()) => { - return (StatusCode::BAD_REQUEST, "invalid websocket request").into_response(); - } - }; - // Row zero: bind the connection to its community from the request host // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound // connection. The host is the authoritative selector; an unmapped host or a @@ -416,14 +408,7 @@ async fn nip11_or_ws_handler( } limit_relay_websocket(ws, max_frame_bytes) .on_upgrade(move |socket| { - handle_connection( - socket, - state, - addr, - tenant, - corporate_identity_assertion, - client_binding_epoch, - ) + handle_connection(socket, state, addr, tenant, corporate_identity_assertion) }) .into_response() } @@ -445,23 +430,6 @@ async fn nip11_or_ws_handler( } } -fn client_binding_epoch_from_headers( - headers: &HeaderMap, -) -> Result, ()> { - let mut values = headers.get_all(CLIENT_BINDING_EPOCH_HEADER).iter(); - let Some(value) = values.next() else { - return Ok(None); - }; - if values.next().is_some() { - return Err(()); - } - let value = value.to_str().map_err(|_| ())?; - if value.contains(',') { - return Err(()); - } - ClientBindingEpoch::parse(value).map(Some).map_err(|_| ()) -} - fn limit_relay_websocket( ws: WebSocketUpgrade, max_frame_bytes: usize, @@ -763,37 +731,4 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } - - #[test] - fn client_binding_epoch_header_is_absent_or_one_exact_canonical_value() { - let epoch = "11".repeat(32); - let mut headers = HeaderMap::new(); - assert_eq!(client_binding_epoch_from_headers(&headers), Ok(None)); - - headers.insert( - CLIENT_BINDING_EPOCH_HEADER, - axum::http::HeaderValue::from_str(&epoch).expect("canonical header value"), - ); - assert_eq!( - client_binding_epoch_from_headers(&headers), - Ok(Some( - ClientBindingEpoch::parse(&epoch).expect("canonical epoch") - )) - ); - - for invalid in ["AA".repeat(32), "11".repeat(31), format!("{epoch},x")] { - let mut headers = HeaderMap::new(); - headers.insert( - CLIENT_BINDING_EPOCH_HEADER, - axum::http::HeaderValue::from_str(&invalid).expect("HTTP-safe test value"), - ); - assert_eq!(client_binding_epoch_from_headers(&headers), Err(())); - } - - let mut duplicated = HeaderMap::new(); - let value = axum::http::HeaderValue::from_str(&epoch).expect("canonical header value"); - duplicated.append(CLIENT_BINDING_EPOCH_HEADER, value.clone()); - duplicated.append(CLIENT_BINDING_EPOCH_HEADER, value); - assert_eq!(client_binding_epoch_from_headers(&duplicated), Err(())); - } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2ea0d2af1b..55658369be 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -2014,7 +2014,6 @@ mod tests { ), remote_addr: "127.0.0.1:1234".parse().unwrap(), corporate_identity_assertion: None, - client_binding_epoch: None, auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), From 087158bf882c67746dc163721da3c16a6af7c7aa Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:10:44 -0500 Subject: [PATCH 06/40] fix(desktop): prove and fence status socket ownership Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../buzz-core/src/client_binding_bootstrap.rs | 9 - desktop/src-tauri/build.rs | 8 +- .../src/client_binding_status_session.rs | 77 +- desktop/src-tauri/src/commands/identity.rs | 136 +++- desktop/src-tauri/src/commands/workspace.rs | 6 + desktop/src-tauri/src/native_websocket.rs | 679 +++++++++++++----- 6 files changed, 701 insertions(+), 214 deletions(-) diff --git a/crates/buzz-core/src/client_binding_bootstrap.rs b/crates/buzz-core/src/client_binding_bootstrap.rs index c3a8f999f1..5720d94e3e 100644 --- a/crates/buzz-core/src/client_binding_bootstrap.rs +++ b/crates/buzz-core/src/client_binding_bootstrap.rs @@ -38,15 +38,6 @@ impl ClientBindingEpoch { Self(Uuid::new_v4().to_string()) } - /// Construct a canonical UUIDv4 epoch from caller-supplied CSPRNG bytes. - pub fn from_random_bytes(bytes: [u8; 32]) -> Self { - let mut uuid_bytes = [0_u8; 16]; - uuid_bytes.copy_from_slice(&bytes[..16]); - uuid_bytes[6] = (uuid_bytes[6] & 0x0f) | 0x40; - uuid_bytes[8] = (uuid_bytes[8] & 0x3f) | 0x80; - Self(Uuid::from_bytes(uuid_bytes).to_string()) - } - /// Parse the canonical lowercase hyphenated UUIDv4 wire form. pub fn parse(value: &str) -> Result { let parsed = Uuid::parse_str(value) diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index feba2cea1b..2b997af891 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -112,13 +112,7 @@ fn main() { tauri_build::Attributes::new().plugin( "websocket", tauri_build::InlinedPlugin::new() - .commands(&[ - "connect", - "send", - "disconnect", - "disconnect_all", - "current_projection", - ]) + .commands(&["connect", "send", "disconnect", "disconnect_all"]) .default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands), ), ) diff --git a/desktop/src-tauri/src/client_binding_status_session.rs b/desktop/src-tauri/src/client_binding_status_session.rs index aee4ec22cf..8b913a3460 100644 --- a/desktop/src-tauri/src/client_binding_status_session.rs +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -182,7 +182,9 @@ impl ClientBindingStatusSession { // The outer frame is trusted-invalid, but a valid inner status // must still consume its revision so replaying the identical // event later in an exact array cannot restore presentation. - let _ = tracker.accept(&event, now); + if tracker.accept(&event, now).is_err() { + tracker.retain_trusted_invalid_high_water(&event); + } tracker.on_disconnect(); } else { self.bootstrap_latched_invalid = true; @@ -207,7 +209,10 @@ impl ClientBindingStatusSession { connection_epoch: self.connection_epoch.as_str().to_owned(), }) } - Err(_) => self.clear_trusted_invalid(), + Err(_) => { + tracker.retain_trusted_invalid_high_water(&event); + self.clear_trusted_invalid() + } } } @@ -269,7 +274,7 @@ mod tests { } fn epoch() -> ClientBindingEpoch { - ClientBindingEpoch::from_random_bytes([0x11; 32]) + ClientBindingEpoch::parse("11111111-1111-4111-8111-111111111111").expect("synthetic epoch") } fn session(relay: &Keys, author: &Keys) -> ClientBindingStatusSession { @@ -517,6 +522,72 @@ mod tests { ); } + #[test] + fn trusted_invalid_parseable_revision_advances_hidden_high_water() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + let first = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &first), ISSUED_AT), + &author, + 1, + ); + + let revision_four = status( + &relay, + &author, + 4, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v4", + ); + let mut invalid_payload: Value = + serde_json::from_str(&revision_four.content).expect("synthetic status payload"); + invalid_payload["authorization_domain"] = json!("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let trusted_invalid = EventBuilder::new( + Kind::Custom(buzz_core_pkg::kind::KIND_CLIENT_BINDING_STATUS as u16), + invalid_payload.to_string(), + ) + .tags([]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("trusted-invalid status signs"); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &trusted_invalid), + ISSUED_AT, + )); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_four), + ISSUED_AT, + )); + + let revision_five = status( + &relay, + &author, + 5, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v5", + ); + assert_current( + session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_five), + ISSUED_AT, + ), + &author, + 5, + ); + } + #[test] fn withdrawal_and_passive_expiry_clear_current_projection() { let relay = Keys::generate(); diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 83d810e8aa..51f1f8357c 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -10,6 +10,7 @@ use crate::{ nostr_bind, relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; +use buzz_core_pkg::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG; /// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars /// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer. @@ -391,6 +392,10 @@ pub async fn import_identity( }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; + projection_app + .state::() + .invalidate_projection() + .await; Ok(identity) } @@ -549,7 +554,7 @@ pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> { } app.state::() - .invalidate_projection() + .suspend_projection() .await; // Stop all managed agents before restart so they don't race the wipe. @@ -653,26 +658,75 @@ pub async fn create_auth_event( challenge: String, relay_url: String, state: State<'_, AppState>, + websocket_manager: State<'_, crate::native_websocket::WebSocketManager>, + native_websocket_id: Option, ) -> Result { let keys = state.signing_keys()?; + let status_proof = match native_websocket_id { + Some(id) if relay_url == relay_ws_url_with_override(&state) => websocket_manager + .status_auth_proof(id, &challenge, &relay_url, keys.public_key()) + .await + .ok(), + None => None, + Some(_) => None, + }; + let scope_tag = status_proof.as_ref().map(|proof| { + vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + proof.connection_epoch().as_str().to_string(), + proof.relay_signer().to_hex(), + ] + }); + let (ordinary_event_json, scoped_event_json) = + tauri::async_runtime::spawn_blocking(move || { + let ordinary = build_auth_event_json(&keys, &challenge, &relay_url, None)?; + let scoped = scope_tag.and_then(|scope_tag| { + build_auth_event_json(&keys, &challenge, &relay_url, Some(scope_tag)).ok() + }); + Ok::<_, String>((ordinary, scoped)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + if let (Some(id), Some(proof), Some(scoped)) = ( + native_websocket_id, + status_proof.as_ref(), + scoped_event_json, + ) { + if websocket_manager + .complete_status_auth(id, proof) + .await + .is_ok() + { + return Ok(scoped); + } + } + Ok(ordinary_event_json) +} - tauri::async_runtime::spawn_blocking(move || { - let tags = vec![ - Tag::parse(vec!["relay", &relay_url]) - .map_err(|error| format!("relay tag failed: {error}"))?, - Tag::parse(vec!["challenge", &challenge]) - .map_err(|error| format!("challenge tag failed: {error}"))?, - ]; - - let event = EventBuilder::new(Kind::Custom(22242), "") - .tags(tags) - .sign_with_keys(&keys) - .map_err(|error| format!("sign failed: {error}"))?; - - Ok(event.as_json()) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? +fn build_auth_event_json( + keys: &Keys, + challenge: &str, + relay_url: &str, + scope_tag: Option>, +) -> Result { + let mut tags = vec![ + Tag::parse(vec!["relay", relay_url]) + .map_err(|error| format!("relay tag failed: {error}"))?, + Tag::parse(vec!["challenge", challenge]) + .map_err(|error| format!("challenge tag failed: {error}"))?, + ]; + if let Some(scope_tag) = scope_tag { + tags.push( + Tag::parse(scope_tag) + .map_err(|error| format!("client binding scope tag failed: {error}"))?, + ); + } + EventBuilder::new(Kind::Custom(22242), "") + .tags(tags) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign failed: {error}")) } #[tauri::command] @@ -712,8 +766,9 @@ pub async fn nip44_decrypt_from_self( #[cfg(test)] mod nostr_identity_binding_tests { - use super::build_nostr_identity_binding_event; + use super::{build_auth_event_json, build_nostr_identity_binding_event}; use crate::nostr_bind; + use buzz_core_pkg::client_binding_bootstrap::{ClientBindingScopeV1, CLIENT_BINDING_SCOPE_TAG}; use nostr::{JsonUtil, Keys}; fn tag_values(event: &nostr::Event) -> Vec> { @@ -762,6 +817,49 @@ mod nostr_identity_binding_tests { assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),])); } + #[test] + fn auth_builder_adds_scope_only_when_native_proof_supplies_exact_tag() { + let author = Keys::generate(); + let relay = Keys::generate(); + let ordinary = nostr::Event::from_json( + build_auth_event_json(&author, "challenge", "wss://relay.example/", None) + .expect("ordinary AUTH builds"), + ) + .expect("ordinary AUTH parses"); + assert!(matches!( + ClientBindingScopeV1::from_verified_auth_event(&ordinary), + Err(buzz_core_pkg::client_binding_bootstrap::ClientBindingBootstrapError::MissingScopeTag) + )); + + let scoped = nostr::Event::from_json( + build_auth_event_json( + &author, + "challenge", + "wss://relay.example/", + Some(vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + "11111111-1111-4111-8111-111111111111".to_string(), + relay.public_key().to_hex(), + ]), + ) + .expect("scoped AUTH builds"), + ) + .expect("scoped AUTH parses"); + let parsed = + ClientBindingScopeV1::from_verified_auth_event(&scoped).expect("signed scope parses"); + assert_eq!(parsed.relay_signer(), relay.public_key()); + assert_eq!( + scoped + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) + == Some(CLIENT_BINDING_SCOPE_TAG)) + .count(), + 1 + ); + } + #[test] fn build_nostr_identity_binding_event_rejects_malformed_verification_code() { let keys = Keys::generate(); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 83409d51e6..d55cd59d23 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -214,6 +214,12 @@ pub async fn apply_workspace( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; + // Fence work that raced the mutation after the pre-mutation invalidation. + restore_app + .state::() + .invalidate_projection() + .await; + let state = restore_app.state::(); // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 5113e29595..54614cc9bf 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -9,14 +9,13 @@ use tokio_tungstenite::{ connect_async, tungstenite::{ client::IntoClientRequest, - http::HeaderValue, protocol::{frame::coding::CloseCode, CloseFrame, Message}, }, }; use tokio_util::sync::CancellationToken; use url::{Host, Url}; -use buzz_core_pkg::client_binding_bootstrap::{ClientBindingEpoch, CLIENT_BINDING_EPOCH_HEADER}; +use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; use crate::{ app_state::AppState, @@ -96,10 +95,48 @@ struct ConnectionHandle { sender: mpsc::Sender, cancel: CancellationToken, task: Mutex>>, + status_scope: Mutex>, +} + +struct StatusScope { + relay_url: String, + relay_signer: PublicKey, + expected_author: PublicKey, + epoch: ClientBindingEpoch, + projection_channel: Channel, + generation: u64, + challenge: Option, + auth_proven: bool, +} + +struct PreparedStatus { + session: ClientBindingStatusSession, + scope: StatusScope, +} + +pub(crate) struct StatusAuthProof { + handle: Arc, + challenge: String, + relay_url: String, + relay_signer: PublicKey, + expected_author: PublicKey, + epoch: ClientBindingEpoch, + generation: u64, +} + +impl StatusAuthProof { + pub(crate) fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.epoch + } + + pub(crate) const fn relay_signer(&self) -> PublicKey { + self.relay_signer + } } struct ProjectionOwner { id: Id, + handle: Arc, epoch: ClientBindingEpoch, channel: Channel, } @@ -107,6 +144,7 @@ struct ProjectionOwner { #[derive(Default)] struct ProjectionState { generation: u64, + suspended: bool, owner: Option, current: Option, } @@ -143,37 +181,66 @@ impl WebSocketManager { } } - async fn begin_projection_attempt(&self) -> u64 { - let mut projection = self.projection.lock().await; - projection.generation = projection.generation.wrapping_add(1); - if let Some(previous) = projection.owner.take() { - let _ = previous.channel.send(serde_json::Value::Null); - } - projection.current = None; - projection.generation + async fn projection_generation(&self) -> u64 { + self.projection.lock().await.generation } - async fn activate_projection( - &self, - generation: u64, - id: Id, - epoch: ClientBindingEpoch, - channel: Channel, - ) -> bool { + async fn status_generation(&self) -> Option { + let projection = self.projection.lock().await; + (!projection.suspended).then_some(projection.generation) + } + + async fn current_connect_cancel(&self) -> CancellationToken { + self.connect_cancel.lock().await.clone() + } + + async fn activate_projection_after_auth(&self, id: Id, proof: &StatusAuthProof) -> bool { + if !self + .connections + .lock() + .await + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, &proof.handle)) + { + return false; + } let mut projection = self.projection.lock().await; - if projection.generation != generation { - let _ = channel.send(serde_json::Value::Null); + if projection.suspended || projection.generation != proof.generation { + return false; + } + let mut status_scope = proof.handle.status_scope.lock().await; + let Some(scope) = status_scope.as_mut() else { + return false; + }; + if scope.generation != proof.generation + || scope.auth_proven + || scope.challenge.as_deref() != Some(proof.challenge.as_str()) + || scope.relay_url != proof.relay_url + || scope.relay_signer != proof.relay_signer + || scope.expected_author != proof.expected_author + || scope.epoch != proof.epoch + { return false; } + scope.auth_proven = true; + if let Some(previous) = projection.owner.take() { + let _ = previous.channel.send(serde_json::Value::Null); + } projection.current = None; - let _ = channel.send(serde_json::Value::Null); - projection.owner = Some(ProjectionOwner { id, epoch, channel }); + let _ = scope.projection_channel.send(serde_json::Value::Null); + projection.owner = Some(ProjectionOwner { + id, + handle: Arc::clone(&proof.handle), + epoch: proof.epoch.clone(), + channel: scope.projection_channel.clone(), + }); true } async fn apply_projection_update( &self, id: Id, + handle: &Arc, epoch: &ClientBindingEpoch, update: ProjectionUpdate, ) { @@ -184,7 +251,7 @@ impl WebSocketManager { let Some(owner) = projection.owner.as_ref() else { return; }; - if owner.id != id || owner.epoch != *epoch { + if owner.id != id || !Arc::ptr_eq(&owner.handle, handle) || owner.epoch != *epoch { return; } projection.current = match update { @@ -201,13 +268,16 @@ impl WebSocketManager { } } - async fn clear_projection_if_owner(&self, id: Id, epoch: &ClientBindingEpoch) { + async fn clear_projection_if_owner( + &self, + id: Id, + handle: &Arc, + epoch: &ClientBindingEpoch, + ) { let mut projection = self.projection.lock().await; - if projection - .owner - .as_ref() - .is_some_and(|owner| owner.id == id && owner.epoch == *epoch) - { + if projection.owner.as_ref().is_some_and(|owner| { + owner.id == id && Arc::ptr_eq(&owner.handle, handle) && owner.epoch == *epoch + }) { if let Some(owner) = projection.owner.take() { let _ = owner.channel.send(serde_json::Value::Null); } @@ -218,27 +288,127 @@ impl WebSocketManager { /// Invalidate all browser-visible status and revoke the current socket's /// ownership. Late work from that socket is rejected by the owner fence. pub(crate) async fn invalidate_projection(&self) { - let mut projection = self.projection.lock().await; - projection.generation = projection.generation.wrapping_add(1); - if let Some(owner) = projection.owner.take() { - let _ = owner.channel.send(serde_json::Value::Null); + { + let mut projection = self.projection.lock().await; + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; } - projection.current = None; + self.cancel_status_connections().await; } - async fn current_projection(&self) -> Option { - let mut projection = self.projection.lock().await; - if projection - .current - .as_ref() - .is_some_and(|current| unix_now() >= current.fresh_until) + /// Permanently disable status projection for the remainder of this process. + /// Sign-out uses this before starting restart so no racing webview request + /// can regain presentation ownership with the retiring identity. + pub(crate) async fn suspend_projection(&self) { { - projection.current = None; - if let Some(owner) = projection.owner.as_ref() { + let mut projection = self.projection.lock().await; + projection.suspended = true; + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { let _ = owner.channel.send(serde_json::Value::Null); } + projection.current = None; + } + self.cancel_status_connections().await; + } + + async fn cancel_status_connections(&self) { + let handles = self + .connections + .lock() + .await + .values() + .cloned() + .collect::>(); + for handle in handles { + if handle.status_scope.lock().await.take().is_some() { + handle.cancel.cancel(); + } + } + } + + async fn record_status_challenge( + &self, + id: Id, + handle: &Arc, + challenge: &str, + ) { + if !self + .connections + .lock() + .await + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, handle)) + { + return; + } + let mut status_scope = handle.status_scope.lock().await; + let Some(scope) = status_scope.as_mut() else { + return; + }; + match scope.challenge.as_deref() { + None => scope.challenge = Some(challenge.to_owned()), + Some(existing) if existing == challenge => {} + Some(_) => { + status_scope.take(); + } + } + } + + pub(crate) async fn status_auth_proof( + &self, + id: Id, + challenge: &str, + relay_url: &str, + expected_author: PublicKey, + ) -> Result { + let handle = self + .connections + .lock() + .await + .get(&id) + .cloned() + .ok_or_else(|| "native WebSocket is not current".to_string())?; + let current_generation = self + .status_generation() + .await + .ok_or_else(|| "native WebSocket status is suspended".to_string())?; + let scope = handle.status_scope.lock().await; + let scope = scope + .as_ref() + .ok_or_else(|| "native WebSocket is not status-capable".to_string())?; + if scope.auth_proven + || scope.challenge.as_deref() != Some(challenge) + || scope.relay_url != relay_url + || scope.expected_author != expected_author + || scope.generation != current_generation + { + return Err("native WebSocket status scope does not match".to_string()); + } + Ok(StatusAuthProof { + handle: Arc::clone(&handle), + challenge: challenge.to_owned(), + relay_url: scope.relay_url.clone(), + relay_signer: scope.relay_signer, + expected_author: scope.expected_author, + epoch: scope.epoch.clone(), + generation: scope.generation, + }) + } + + pub(crate) async fn complete_status_auth( + &self, + id: Id, + proof: &StatusAuthProof, + ) -> Result<(), String> { + if self.activate_projection_after_auth(id, proof).await { + Ok(()) + } else { + Err("native WebSocket status scope changed while signing".to_string()) } - projection.current.clone() } async fn disconnect_handle(handle: Arc) { @@ -262,14 +432,29 @@ impl WebSocketManager { .await .owner .as_ref() - .filter(|owner| owner.id == id) + .filter(|owner| owner.id == id && Arc::ptr_eq(&owner.handle, &handle)) .map(|owner| owner.epoch.clone()); if let Some(epoch) = owner_epoch { - self.clear_projection_if_owner(id, &epoch).await; + self.clear_projection_if_owner(id, &handle, &epoch).await; } Self::disconnect_handle(handle).await; } } + + async fn disconnect_all(&self) { + self.invalidate_projection().await; + let mut connect_cancel = self.connect_cancel.lock().await; + connect_cancel.cancel(); + *connect_cancel = CancellationToken::new(); + let handles = { + let mut connections = self.connections.lock().await; + connections + .drain() + .map(|(_, handle)| handle) + .collect::>() + }; + futures_util::future::join_all(handles.into_iter().map(Self::disconnect_handle)).await; + } } #[cfg(test)] @@ -278,28 +463,20 @@ async fn open_connection( url: &str, on_message: Channel, ) -> Result { - open_connection_with_projection(manager, url, on_message, None, None, None).await + let connect_cancel = manager.current_connect_cancel().await; + open_connection_with_projection(manager, url, on_message, None, connect_cancel).await } async fn open_connection_with_projection( manager: &WebSocketManager, url: &str, on_message: Channel, - mut status_session: Option, - projection_channel: Option>, - projection_generation: Option, + prepared_status: Option, + connect_cancel: CancellationToken, ) -> Result { - let connect_cancel = manager.connect_cancel.lock().await.clone(); - let mut request = url + let request = url .into_client_request() .map_err(|error| error.to_string())?; - if let Some(session) = status_session.as_ref() { - request.headers_mut().insert( - CLIENT_BINDING_EPOCH_HEADER, - HeaderValue::from_str(session.connection_epoch().as_str()) - .map_err(|_| "invalid WebSocket request".to_string())?, - ); - } let (socket, _) = tokio::select! { _ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()), result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) => result @@ -313,6 +490,13 @@ async fn open_connection_with_projection( if connect_cancel.is_cancelled() { return Err("WebSocket connection cancelled".to_string()); } + let current_generation = manager.projection_generation().await; + if prepared_status + .as_ref() + .is_some_and(|prepared| prepared.scope.generation != current_generation) + { + return Err("WebSocket connection scope changed".to_string()); + } let id = loop { let candidate = uuid::Uuid::new_v4().as_u128() as u32; @@ -326,26 +510,32 @@ async fn open_connection_with_projection( sender, cancel: cancel.clone(), task: Mutex::new(None), + status_scope: Mutex::new(prepared_status.as_ref().map(|prepared| StatusScope { + relay_url: prepared.scope.relay_url.clone(), + relay_signer: prepared.scope.relay_signer, + expected_author: prepared.scope.expected_author, + epoch: prepared.scope.epoch.clone(), + projection_channel: prepared.scope.projection_channel.clone(), + generation: prepared.scope.generation, + challenge: None, + auth_proven: false, + })), }); let mut task_slot = handle.task.lock().await; manager.connections.lock().await.insert(id, handle.clone()); - let activated = match ( - status_session.as_ref(), - projection_channel.clone(), - projection_generation, - ) { - (Some(session), Some(channel), Some(generation)) => { - manager - .activate_projection(generation, id, session.connection_epoch().clone(), channel) - .await - } - _ => false, - }; - if !activated { - status_session = None; + let registered_generation = manager.projection_generation().await; + if prepared_status + .as_ref() + .is_some_and(|prepared| prepared.scope.generation != registered_generation) + { + manager.remove_if_current(id, &handle).await; + handle.cancel.cancel(); + return Err("WebSocket connection scope changed".to_string()); } + let status_session = prepared_status.map(|prepared| prepared.session); + let task_manager = manager.clone(); let task = tauri::async_runtime::spawn(run_connection_inner( id, @@ -372,24 +562,31 @@ async fn connect( on_projection: Option>, _config: Option, ) -> Result { - let projection_generation = match on_projection.as_ref() { - Some(_) => Some(manager.begin_projection_attempt().await), - None => None, + let connect_cancel = manager.current_connect_cancel().await; + let generation = manager.status_generation().await; + let prepared_status = match (on_projection, generation) { + (Some(channel), Some(generation)) => tokio::select! { + _ = connect_cancel.cancelled() => { + return Err("WebSocket connection cancelled".to_string()); + } + prepared = prepare_status_session(&state, &url, channel, generation) => prepared, + }, + _ => None, }; - if let Some(channel) = on_projection.as_ref() { - let _ = channel.send(serde_json::Value::Null); + if prepared_status.is_some() + && manager.status_generation().await + != prepared_status + .as_ref() + .map(|prepared| prepared.scope.generation) + { + return Err("WebSocket connection scope changed".to_string()); } - let status_session = match on_projection.as_ref() { - Some(_) => prepare_status_session(&state, &url).await, - None => None, - }; open_connection_with_projection( manager.inner(), &url, on_message, - status_session, - on_projection, - projection_generation, + prepared_status, + connect_cancel, ) .await } @@ -397,19 +594,28 @@ async fn connect( async fn prepare_status_session( state: &AppState, requested_url: &str, -) -> Option { + projection_channel: Channel, + generation: u64, +) -> Option { if requested_url != crate::relay::relay_ws_url_with_override(state) { return None; } let expected_author = state.signing_keys().ok()?.public_key(); let relay_signer = fetch_nip11_signer(requested_url).await.ok()?; - let mut epoch_bytes = [0_u8; 32]; - getrandom::getrandom(&mut epoch_bytes).ok()?; - Some(ClientBindingStatusSession::new( - relay_signer, - expected_author, - ClientBindingEpoch::from_random_bytes(epoch_bytes), - )) + let epoch = ClientBindingEpoch::new_v4(); + Some(PreparedStatus { + session: ClientBindingStatusSession::new(relay_signer, expected_author, epoch.clone()), + scope: StatusScope { + relay_url: requested_url.to_owned(), + relay_signer, + expected_author, + epoch, + projection_channel, + generation, + challenge: None, + auth_proven: false, + }, + }) } #[derive(Deserialize)] @@ -542,19 +748,7 @@ async fn disconnect(manager: tauri::State<'_, WebSocketManager>, id: Id) -> Resu #[tauri::command] async fn disconnect_all(manager: tauri::State<'_, WebSocketManager>) -> Result<(), String> { - manager.invalidate_projection().await; - let mut connect_cancel = manager.connect_cancel.lock().await; - connect_cancel.cancel(); - *connect_cancel = CancellationToken::new(); - let handles = { - let mut connections = manager.connections.lock().await; - connections - .drain() - .map(|(_, handle)| handle) - .collect::>() - }; - futures_util::future::join_all(handles.into_iter().map(WebSocketManager::disconnect_handle)) - .await; + manager.disconnect_all().await; Ok(()) } @@ -607,7 +801,7 @@ async fn run_connection_inner( if let Some(session) = status_session.as_mut() { let epoch = session.connection_epoch().clone(); let update = session.expire(unix_now()); - manager.apply_projection_update(id, &epoch, update).await; + manager.apply_projection_update(id, &handle, &epoch, update).await; } } _ = cancel.cancelled() => { @@ -633,17 +827,38 @@ async fn run_connection_inner( incoming = socket.next() => { let message = match incoming { Some(Ok(message)) => { - let reserved_text = match &message { - Message::Text(value) => Some(value.as_str()), - Message::Binary(value) => std::str::from_utf8(value).ok(), - _ => None, + if let Message::Text(text) = &message { + if let Some(challenge) = nip42_challenge(text) { + manager + .record_status_challenge(id, &handle, &challenge) + .await; + } } - .filter(|text| is_reserved_text(text)); + let reserved_text = reserved_text_message(&message); if let Some(text) = reserved_text { - if let Some(session) = status_session.as_mut() { + if let Some(mut session) = status_session.take() { let epoch = session.connection_epoch().clone(); - if let Some(update) = session.consume_text(text, unix_now()) { - manager.apply_projection_update(id, &epoch, update).await; + let folded = tauri::async_runtime::spawn_blocking(move || { + let update = session.consume_text(&text, unix_now()); + (session, update) + }) + .await; + match folded { + Ok((returned_session, update)) => { + status_session = Some(returned_session); + if let Some(update) = update { + manager + .apply_projection_update( + id, &handle, &epoch, update, + ) + .await; + } + } + Err(_) => { + manager + .clear_projection_if_owner(id, &handle, &epoch) + .await; + } } } continue; @@ -664,12 +879,30 @@ async fn run_connection_inner( if let Some(session) = status_session.as_mut() { let epoch = session.connection_epoch().clone(); let update = session.disconnect(); - manager.apply_projection_update(id, &epoch, update).await; - manager.clear_projection_if_owner(id, &epoch).await; + manager + .apply_projection_update(id, &handle, &epoch, update) + .await; + manager.clear_projection_if_owner(id, &handle, &epoch).await; } manager.remove_if_current(id, &handle).await; } +fn reserved_text_message(message: &Message) -> Option { + match message { + Message::Text(value) if is_reserved_text(value) => Some(value.to_string()), + _ => None, + } +} + +fn nip42_challenge(text: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + let values = value.as_array()?; + if values.len() != 2 || values.first().and_then(serde_json::Value::as_str) != Some("AUTH") { + return None; + } + values.get(1)?.as_str().map(str::to_owned) +} + fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -697,8 +930,7 @@ pub fn init() -> TauriPlugin { connect, send, disconnect, - disconnect_all, - current_projection + disconnect_all ]) .setup(|app, _api| { app.manage(WebSocketManager::default()); @@ -707,13 +939,6 @@ pub fn init() -> TauriPlugin { .build() } -#[tauri::command] -async fn current_projection( - manager: tauri::State<'_, WebSocketManager>, -) -> Option { - manager.current_projection().await -} - #[cfg(test)] mod tests { use super::*; @@ -803,6 +1028,7 @@ mod tests { sender, cancel: CancellationToken::new(), task: Mutex::new(None), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(1, handle.clone()); let task = tauri::async_runtime::spawn(run_connection( @@ -847,6 +1073,7 @@ mod tests { ready_tx.send(()).unwrap(); std::future::pending::<()>().await; }))), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(7, handle); ready_rx.await.unwrap(); @@ -872,6 +1099,7 @@ mod tests { task: Mutex::new(Some(tauri::async_runtime::spawn(async { std::future::pending::<()>().await; }))), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(1, handle); gate.cancel(); @@ -892,6 +1120,19 @@ mod tests { assert!(manager.connect_cancel.try_lock().is_ok()); } + #[tokio::test] + async fn disconnect_all_cancels_pending_connect_generation() { + let manager = WebSocketManager::default(); + let pending = manager.current_connect_cancel().await; + let generation = manager.projection_generation().await; + + manager.disconnect_all().await; + + assert!(pending.is_cancelled()); + assert!(!manager.current_connect_cancel().await.is_cancelled()); + assert_ne!(manager.projection_generation().await, generation); + } + #[tokio::test] async fn one_connection_does_not_block_another_send_queue() { let manager = WebSocketManager::default(); @@ -907,6 +1148,7 @@ mod tests { sender: blocked_sender, cancel: CancellationToken::new(), task: Mutex::new(None), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(1, blocked); @@ -915,6 +1157,7 @@ mod tests { sender: healthy_sender.clone(), cancel: CancellationToken::new(), task: Mutex::new(None), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(2, healthy); @@ -960,76 +1203,137 @@ mod tests { )); } + fn test_epoch(suffix: u8) -> ClientBindingEpoch { + ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{suffix:012x}")) + .expect("synthetic epoch") + } + + fn test_handle(status_scope: Option) -> Arc { + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(status_scope), + }) + } + + fn test_status_scope( + generation: u64, + relay: PublicKey, + author: PublicKey, + epoch: ClientBindingEpoch, + ) -> StatusScope { + StatusScope { + relay_url: "ws://localhost:3000/".to_string(), + relay_signer: relay, + expected_author: author, + epoch, + projection_channel: silent_channel(), + generation, + challenge: None, + auth_proven: false, + } + } + #[tokio::test] - async fn projection_generation_and_owner_epoch_fence_late_updates() { + async fn only_proven_status_socket_owns_projection_and_old_handle_is_fenced() { let manager = WebSocketManager::default(); - let stale_generation = manager.begin_projection_attempt().await; - let current_generation = manager.begin_projection_attempt().await; - let stale_epoch = ClientBindingEpoch::from_random_bytes([0x11; 32]); - let current_epoch = ClientBindingEpoch::from_random_bytes([0x22; 32]); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let generation = manager.projection_generation().await; + let old_epoch = test_epoch(0x11); + let old = test_handle(Some(test_status_scope( + generation, + relay, + author, + old_epoch.clone(), + ))); + manager.connections.lock().await.insert(7, old.clone()); + manager + .record_status_challenge(7, &old, "challenge-old") + .await; + let old_proof = manager + .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + manager + .complete_status_auth(7, &old_proof) + .await + .expect("old socket owns projection"); - assert!( - !manager - .activate_projection(stale_generation, 1, stale_epoch.clone(), silent_channel(),) - .await - ); - assert!( - manager - .activate_projection( - current_generation, - 2, - current_epoch.clone(), - silent_channel(), - ) - .await - ); let current = CurrentProjection { - event_author_pubkey: "11".repeat(32), + event_author_pubkey: author.to_hex(), fresh_until: u64::MAX, - connection_epoch: current_epoch.as_str().to_owned(), + connection_epoch: old_epoch.as_str().to_owned(), }; - - manager - .apply_projection_update(1, &stale_epoch, ProjectionUpdate::Current(current.clone())) - .await; - assert!(manager.projection.lock().await.current.is_none()); - manager - .apply_projection_update(2, &stale_epoch, ProjectionUpdate::Current(current.clone())) - .await; - assert!(manager.projection.lock().await.current.is_none()); manager .apply_projection_update( - 2, - ¤t_epoch, + 7, + &old, + &old_epoch, ProjectionUpdate::Current(current.clone()), ) .await; assert_eq!(manager.projection.lock().await.current, Some(current)); - let next_generation = manager.begin_projection_attempt().await; - assert!(manager.projection.lock().await.current.is_none()); - assert!( - manager - .activate_projection( - next_generation, - 3, - ClientBindingEpoch::from_random_bytes([0x33; 32]), - silent_channel(), - ) - .await - ); + assert!(manager + .status_auth_proof(7, "challenge-old", "ws://wrong/", author) + .await + .is_err()); + assert!(manager.projection.lock().await.current.is_some()); + + let new_epoch = test_epoch(0x22); + let new = test_handle(Some(test_status_scope( + generation, + relay, + author, + new_epoch.clone(), + ))); + manager.connections.lock().await.insert(7, new.clone()); + manager + .record_status_challenge(7, &new, "challenge-new") + .await; + let new_proof = manager + .status_auth_proof(7, "challenge-new", "ws://localhost:3000/", author) + .await + .expect("replacement proof"); + manager + .complete_status_auth(7, &new_proof) + .await + .expect("replacement owns projection"); + manager.clear_projection_if_owner(7, &old, &old_epoch).await; manager .apply_projection_update( - 2, - ¤t_epoch, + 7, + &old, + &old_epoch, ProjectionUpdate::Current(CurrentProjection { - event_author_pubkey: "22".repeat(32), + event_author_pubkey: "11".repeat(32), fresh_until: u64::MAX, - connection_epoch: current_epoch.as_str().to_owned(), + connection_epoch: old_epoch.as_str().to_owned(), }), ) .await; assert!(manager.projection.lock().await.current.is_none()); + + let read_only = test_handle(None); + manager + .connections + .lock() + .await + .insert(8, read_only.clone()); + assert!(manager + .status_auth_proof(8, "challenge", "ws://localhost:3000/", author) + .await + .is_err()); + + manager.invalidate_projection().await; + assert!(new.cancel.is_cancelled()); + assert!(manager.projection.lock().await.owner.is_none()); + assert!(manager.complete_status_auth(7, &new_proof).await.is_err()); + manager.suspend_projection().await; + assert!(manager.status_generation().await.is_none()); } #[tokio::test] @@ -1040,12 +1344,14 @@ mod tests { sender: old_sender, cancel: CancellationToken::new(), task: Mutex::new(None), + status_scope: Mutex::new(None), }); let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); let current = Arc::new(ConnectionHandle { sender: new_sender, cancel: CancellationToken::new(), task: Mutex::new(None), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(9, old.clone()); manager.connections.lock().await.insert(9, current.clone()); @@ -1062,7 +1368,7 @@ mod tests { } #[tokio::test] - async fn reserved_frames_are_swallowed_without_an_eligible_session() { + async fn reserved_text_is_swallowed_but_binary_remains_raw_delivery() { let manager = WebSocketManager::default(); let delivered = Arc::new(AtomicUsize::new(0)); let delivered_for_channel = delivered.clone(); @@ -1080,6 +1386,7 @@ mod tests { sender, cancel: CancellationToken::new(), task: Mutex::new(None), + status_scope: Mutex::new(None), }); manager.connections.lock().await.insert(42, handle.clone()); let task = tauri::async_runtime::spawn(run_connection( @@ -1130,9 +1437,29 @@ mod tests { .expect("connection task joins"); assert_eq!( delivered.load(Ordering::SeqCst), - 2, - "only the ordinary text and terminal close reach raw delivery" + 3, + "binary, ordinary text, and terminal close reach raw delivery" ); assert!(!manager.connections.lock().await.contains_key(&42)); } + + #[test] + fn reserved_classifier_never_intercepts_binary() { + let reserved = + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]).to_string(); + assert!(reserved_text_message(&Message::Text(reserved.clone().into())).is_some()); + assert!(reserved_text_message(&Message::Binary(reserved.into_bytes().into())).is_none()); + } + + #[test] + fn auth_challenge_recording_requires_exact_frame_shape() { + assert_eq!( + nip42_challenge(&serde_json::json!(["AUTH", "exact"]).to_string()).as_deref(), + Some("exact") + ); + assert!( + nip42_challenge(&serde_json::json!(["AUTH", "exact", "extra"]).to_string()).is_none() + ); + assert!(nip42_challenge("not-json").is_none()); + } } From a49de9945ed3c4cfeca9d76db7bb80ecfa2bc9d0 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:10:50 -0500 Subject: [PATCH 07/40] test(binding): lock native status ownership boundary Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../tests/nip_fi_runtime_conformance.rs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs index 507b42d98b..4bec89227d 100644 --- a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -13,6 +13,8 @@ const PRODUCTION_RUNTIME: &str = include_str!("../src/authorization_runtime/prod const AUTH_HANDLER: &str = include_str!("../src/handlers/auth.rs"); const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs"); const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs"); +const READ_ONLY_RELAY_CLIENT: &str = + include_str!("../../../desktop/src/shared/api/readOnlyRelayClient.ts"); #[test] fn mandatory_o4_security_contracts_are_present() { @@ -318,9 +320,9 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { continue; } let source = fs::read_to_string(&file).expect("source file is readable"); - let native_consumer = file - == repo.join("desktop/src-tauri/src/client_binding_status_session.rs") - || file == repo.join("desktop/src-tauri/src/native_websocket.rs"); + let native_session = + file == repo.join("desktop/src-tauri/src/client_binding_status_session.rs"); + let native_socket = file == repo.join("desktop/src-tauri/src/native_websocket.rs"); let native_module_declaration = file == repo.join("desktop/src-tauri/src/lib.rs"); if native_module_declaration { assert_eq!( @@ -341,9 +343,21 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { "24244", "deliver_verification_only", ] { - if native_consumer - && matches!(forbidden, "ClientBindingStatus" | "client_binding_status") - { + let expected_native_count = match (native_session, native_socket, forbidden) { + (true, false, "KIND_CLIENT_BINDING_STATUS") => Some(1), + (true, false, "ClientBindingStatus") => Some(33), + (true, false, "client_binding_status") => Some(2), + (false, true, "ClientBindingStatus") => Some(5), + (false, true, "client_binding_status") => Some(1), + _ => None, + }; + if let Some(expected) = expected_native_count { + assert_eq!( + source_to_scan.matches(forbidden).count(), + expected, + "{} changed the narrow native status allowance for {forbidden}", + file.display() + ); continue; } assert!( @@ -392,8 +406,13 @@ fn auth_bootstrap_precedes_status_and_success_ack() { bootstrap < presentation && presentation < success, "bootstrap must be queued before O4 status and the NIP-42 OK" ); - assert!(AUTH_HANDLER.contains("if let (Some(runtime), Some(assertion), Some(epoch)) =")); + assert!(AUTH_HANDLER.contains("ClientBindingScopeV1::from_verified_auth_event")); + assert!(AUTH_HANDLER.contains("scope.relay_signer() == state.relay_keypair.public_key()")); + assert!(AUTH_HANDLER.contains("if let (Some(runtime), Some(assertion), Some(scope)) =")); assert!(AUTH_HANDLER.contains("let presentation = if bootstrap_queued")); + assert!(!include_str!("../src/router.rs").contains("CLIENT_BINDING_EPOCH_HEADER")); + assert!(!READ_ONLY_RELAY_CLIENT.contains("onProjection")); + assert!(!READ_ONLY_RELAY_CLIENT.contains("nativeWebsocketId")); } #[test] From 123d5b8d987628e2442a05671441272072845703 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:24:02 -0500 Subject: [PATCH 08/40] fix(desktop): close status concurrency races Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../tests/nip_fi_runtime_conformance.rs | 43 ++ desktop/src-tauri/src/commands/identity.rs | 10 +- desktop/src-tauri/src/commands/workspace.rs | 12 +- desktop/src-tauri/src/native_websocket.rs | 566 +++++++++++++++--- 4 files changed, 542 insertions(+), 89 deletions(-) diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs index 4bec89227d..989d9eaeb7 100644 --- a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -15,6 +15,9 @@ const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs"); const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs"); const READ_ONLY_RELAY_CLIENT: &str = include_str!("../../../desktop/src/shared/api/readOnlyRelayClient.ts"); +const WORKSPACE_COMMAND: &str = + include_str!("../../../desktop/src-tauri/src/commands/workspace.rs"); +const IDENTITY_COMMAND: &str = include_str!("../../../desktop/src-tauri/src/commands/identity.rs"); #[test] fn mandatory_o4_security_contracts_are_present() { @@ -390,6 +393,46 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { assert!(!status.contains("std::env")); } +#[test] +fn scope_mutations_finalize_the_status_fence_before_propagating_failure() { + let workspace = WORKSPACE_COMMAND + .split("pub async fn apply_workspace") + .nth(1) + .and_then(|suffix| suffix.split("#[tauri::command]").next()) + .expect("workspace command body exists"); + let identity = IDENTITY_COMMAND + .split("pub async fn import_identity") + .nth(1) + .and_then(|suffix| suffix.split("/// Commit an imported identity").next()) + .expect("identity command body exists"); + + for (name, command, result_propagation) in [ + ("workspace", workspace, "mutation_result?"), + ("identity", identity, "let identity = identity_result?"), + ] { + let begin = command + .find(".begin_scope_mutation()") + .unwrap_or_else(|| panic!("{name} must enter the status mutation fence")); + let blocking = command + .find("spawn_blocking") + .unwrap_or_else(|| panic!("{name} blocking mutation exists")); + let finish = command + .find(".finish_scope_mutation()") + .unwrap_or_else(|| panic!("{name} must exit the status mutation fence")); + let propagate = command + .find(result_propagation) + .unwrap_or_else(|| panic!("{name} must propagate its stored result")); + assert!( + begin < blocking && blocking < finish && finish < propagate, + "{name} must finalize the status fence on success, error, or join failure" + ); + assert!( + !command.contains(".invalidate_projection()"), + "{name} must not leave a reconnect gap between independent invalidations" + ); + } +} + #[test] fn auth_bootstrap_precedes_status_and_success_ack() { let bootstrap = AUTH_HANDLER diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 51f1f8357c..6a025c0bf9 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -343,9 +343,9 @@ pub async fn import_identity( let projection_app = app_handle.clone(); projection_app .state::() - .invalidate_projection() + .begin_scope_mutation() .await; - let identity = tokio::task::spawn_blocking(move || { + let identity_result = tokio::task::spawn_blocking(move || { // NIP-49 backups require a passphrase and decrypt entirely in Rust. // Raw nsec/hex input follows the existing parser path unchanged. let password = password.map(zeroize::Zeroizing::new); @@ -391,11 +391,13 @@ pub async fn import_identity( }) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))??; + .map_err(|e| format!("spawn_blocking failed: {e}")) + .and_then(|result| result); projection_app .state::() - .invalidate_projection() + .finish_scope_mutation() .await; + let identity = identity_result?; Ok(identity) } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index d55cd59d23..ac35a4b3f1 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -133,9 +133,9 @@ pub async fn apply_workspace( ) -> Result<(), String> { let restore_app = app.clone(); app.state::() - .invalidate_projection() + .begin_scope_mutation() .await; - tokio::task::spawn_blocking(move || { + let mutation_result = tokio::task::spawn_blocking(move || { let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── @@ -212,13 +212,15 @@ pub async fn apply_workspace( Ok::<(), String>(()) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))??; + .map_err(|e| format!("spawn_blocking failed: {e}")) + .and_then(|result| result); - // Fence work that raced the mutation after the pre-mutation invalidation. + // Always exit the fence, including closure errors and blocking-task panics. restore_app .state::() - .invalidate_projection() + .finish_scope_mutation() .await; + mutation_result?; let state = restore_app.state::(); // Backfill this exact relay+owner scope only after the workspace has been diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 54614cc9bf..48a43cf2fd 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -96,6 +96,40 @@ struct ConnectionHandle { cancel: CancellationToken, task: Mutex>>, status_scope: Mutex>, + #[cfg(test)] + fold_pause: Option>, +} + +#[cfg(test)] +struct TestFoldPause { + entered: std::sync::atomic::AtomicBool, + released: std::sync::Mutex, + released_cv: std::sync::Condvar, +} + +#[cfg(test)] +impl TestFoldPause { + fn new() -> Self { + Self { + entered: std::sync::atomic::AtomicBool::new(false), + released: std::sync::Mutex::new(false), + released_cv: std::sync::Condvar::new(), + } + } + + fn block(&self) { + self.entered + .store(true, std::sync::atomic::Ordering::SeqCst); + let mut released = self.released.lock().expect("fold-pause lock"); + while !*released { + released = self.released_cv.wait(released).expect("fold-pause wait"); + } + } + + fn release(&self) { + *self.released.lock().expect("fold-pause lock") = true; + self.released_cv.notify_all(); + } } struct StatusScope { @@ -105,6 +139,7 @@ struct StatusScope { epoch: ClientBindingEpoch, projection_channel: Channel, generation: u64, + attempt: u64, challenge: Option, auth_proven: bool, } @@ -122,6 +157,7 @@ pub(crate) struct StatusAuthProof { expected_author: PublicKey, epoch: ClientBindingEpoch, generation: u64, + attempt: u64, } impl StatusAuthProof { @@ -138,12 +174,16 @@ struct ProjectionOwner { id: Id, handle: Arc, epoch: ClientBindingEpoch, + attempt: u64, + presentation_token: u64, channel: Channel, } #[derive(Default)] struct ProjectionState { generation: u64, + attempt_head: u64, + mutation_depth: u64, suspended: bool, owner: Option, current: Option, @@ -181,13 +221,32 @@ impl WebSocketManager { } } + #[cfg(test)] async fn projection_generation(&self) -> u64 { self.projection.lock().await.generation } - async fn status_generation(&self) -> Option { + async fn status_head(&self) -> Option<(u64, u64)> { let projection = self.projection.lock().await; - (!projection.suspended).then_some(projection.generation) + (!projection.suspended && projection.mutation_depth == 0) + .then_some((projection.generation, projection.attempt_head)) + } + + async fn begin_status_attempt(&self) -> Option<(u64, u64)> { + let mut projection = self.projection.lock().await; + if projection.suspended || projection.mutation_depth != 0 { + return None; + } + let Some(next_attempt) = projection.attempt_head.checked_add(1) else { + projection.suspended = true; + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + return None; + }; + projection.attempt_head = next_attempt; + Some((projection.generation, projection.attempt_head)) } async fn current_connect_cancel(&self) -> CancellationToken { @@ -205,7 +264,11 @@ impl WebSocketManager { return false; } let mut projection = self.projection.lock().await; - if projection.suspended || projection.generation != proof.generation { + if projection.suspended + || projection.mutation_depth != 0 + || projection.generation != proof.generation + || projection.attempt_head != proof.attempt + { return false; } let mut status_scope = proof.handle.status_scope.lock().await; @@ -219,6 +282,7 @@ impl WebSocketManager { || scope.relay_signer != proof.relay_signer || scope.expected_author != proof.expected_author || scope.epoch != proof.epoch + || scope.attempt != proof.attempt { return false; } @@ -232,6 +296,8 @@ impl WebSocketManager { id, handle: Arc::clone(&proof.handle), epoch: proof.epoch.clone(), + attempt: proof.attempt, + presentation_token: 0, channel: scope.projection_channel.clone(), }); true @@ -248,23 +314,113 @@ impl WebSocketManager { return; } let mut projection = self.projection.lock().await; - let Some(owner) = projection.owner.as_ref() else { - return; - }; - if owner.id != id || !Arc::ptr_eq(&owner.handle, handle) || owner.epoch != *epoch { + let exhausted_channel = projection.owner.as_ref().and_then(|owner| { + (owner.id == id + && Arc::ptr_eq(&owner.handle, handle) + && owner.epoch == *epoch + && owner.presentation_token == u64::MAX) + .then(|| owner.channel.clone()) + }); + if let Some(channel) = exhausted_channel { + projection.owner = None; + projection.current = None; + let _ = channel.send(serde_json::Value::Null); return; } - projection.current = match update { - ProjectionUpdate::Current(current) => Some(current), - ProjectionUpdate::Clear | ProjectionUpdate::Unchanged => None, + let owner_state = { + let Some(owner) = projection.owner.as_mut() else { + return; + }; + if owner.id != id || !Arc::ptr_eq(&owner.handle, handle) || owner.epoch != *epoch { + return; + } + owner.presentation_token = owner + .presentation_token + .checked_add(1) + .expect("presentation token exhaustion handled above"); + ( + owner.id, + Arc::clone(&owner.handle), + owner.epoch.clone(), + owner.attempt, + owner.presentation_token, + owner.channel.clone(), + ) + }; + let expiry = match update { + ProjectionUpdate::Current(current) if unix_now() < current.fresh_until => { + let fresh_until = current.fresh_until; + projection.current = Some(current); + Some(( + owner_state.0, + owner_state.1, + owner_state.2, + owner_state.3, + owner_state.4, + fresh_until, + )) + } + ProjectionUpdate::Current(_) + | ProjectionUpdate::Clear + | ProjectionUpdate::Unchanged => { + projection.current = None; + None + } }; let value = projection .current .as_ref() .and_then(|current| serde_json::to_value(current).ok()) .unwrap_or(serde_json::Value::Null); - if let Some(owner) = projection.owner.as_ref() { - let _ = owner.channel.send(value); + let _ = owner_state.5.send(value); + drop(projection); + + if let Some((id, handle, epoch, attempt, presentation_token, fresh_until)) = expiry { + let manager = self.clone(); + let _ = tauri::async_runtime::spawn(async move { + tokio::time::sleep(duration_until_unix_second(fresh_until)).await; + manager + .expire_projection_if_owner( + id, + &handle, + &epoch, + attempt, + presentation_token, + fresh_until, + ) + .await; + }); + } + } + + async fn expire_projection_if_owner( + &self, + id: Id, + handle: &Arc, + epoch: &ClientBindingEpoch, + attempt: u64, + presentation_token: u64, + fresh_until: u64, + ) { + let mut projection = self.projection.lock().await; + let matches_current = projection.owner.as_ref().is_some_and(|owner| { + owner.id == id + && Arc::ptr_eq(&owner.handle, handle) + && owner.epoch == *epoch + && owner.attempt == attempt + && owner.presentation_token == presentation_token + }) && projection + .current + .as_ref() + .is_some_and(|current| current.fresh_until == fresh_until) + && unix_now() >= fresh_until; + if !matches_current { + return; + } + projection.current = None; + if let Some(owner) = projection.owner.as_mut() { + owner.presentation_token = owner.presentation_token.saturating_add(1); + let _ = owner.channel.send(serde_json::Value::Null); } } @@ -299,6 +455,36 @@ impl WebSocketManager { self.cancel_status_connections().await; } + /// Enter a fail-closed workspace or identity mutation interval. + /// Overlapping mutations keep status disabled until the last one exits. + pub(crate) async fn begin_scope_mutation(&self) { + { + let mut projection = self.projection.lock().await; + projection.mutation_depth = projection.mutation_depth.saturating_add(1); + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + self.cancel_status_connections().await; + } + + /// Exit one workspace or identity mutation interval and fence all work + /// that raced the mutation, including failed or panicked blocking work. + pub(crate) async fn finish_scope_mutation(&self) { + { + let mut projection = self.projection.lock().await; + projection.mutation_depth = projection.mutation_depth.saturating_sub(1); + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + self.cancel_status_connections().await; + } + /// Permanently disable status projection for the remainder of this process. /// Sign-out uses this before starting restart so no racing webview request /// can regain presentation ownership with the retiring identity. @@ -372,8 +558,8 @@ impl WebSocketManager { .get(&id) .cloned() .ok_or_else(|| "native WebSocket is not current".to_string())?; - let current_generation = self - .status_generation() + let current_head = self + .status_head() .await .ok_or_else(|| "native WebSocket status is suspended".to_string())?; let scope = handle.status_scope.lock().await; @@ -384,7 +570,7 @@ impl WebSocketManager { || scope.challenge.as_deref() != Some(challenge) || scope.relay_url != relay_url || scope.expected_author != expected_author - || scope.generation != current_generation + || (scope.generation, scope.attempt) != current_head { return Err("native WebSocket status scope does not match".to_string()); } @@ -396,6 +582,7 @@ impl WebSocketManager { expected_author: scope.expected_author, epoch: scope.epoch.clone(), generation: scope.generation, + attempt: scope.attempt, }) } @@ -490,11 +677,10 @@ async fn open_connection_with_projection( if connect_cancel.is_cancelled() { return Err("WebSocket connection cancelled".to_string()); } - let current_generation = manager.projection_generation().await; - if prepared_status - .as_ref() - .is_some_and(|prepared| prepared.scope.generation != current_generation) - { + let current_head = manager.status_head().await; + if prepared_status.as_ref().is_some_and(|prepared| { + Some((prepared.scope.generation, prepared.scope.attempt)) != current_head + }) { return Err("WebSocket connection scope changed".to_string()); } @@ -517,18 +703,20 @@ async fn open_connection_with_projection( epoch: prepared.scope.epoch.clone(), projection_channel: prepared.scope.projection_channel.clone(), generation: prepared.scope.generation, + attempt: prepared.scope.attempt, challenge: None, auth_proven: false, })), + #[cfg(test)] + fold_pause: None, }); let mut task_slot = handle.task.lock().await; manager.connections.lock().await.insert(id, handle.clone()); - let registered_generation = manager.projection_generation().await; - if prepared_status - .as_ref() - .is_some_and(|prepared| prepared.scope.generation != registered_generation) - { + let registered_head = manager.status_head().await; + if prepared_status.as_ref().is_some_and(|prepared| { + Some((prepared.scope.generation, prepared.scope.attempt)) != registered_head + }) { manager.remove_if_current(id, &handle).await; handle.cancel.cancel(); return Err("WebSocket connection scope changed".to_string()); @@ -563,21 +751,28 @@ async fn connect( _config: Option, ) -> Result { let connect_cancel = manager.current_connect_cancel().await; - let generation = manager.status_generation().await; - let prepared_status = match (on_projection, generation) { - (Some(channel), Some(generation)) => tokio::select! { + let status_candidate = on_projection.is_some() + && url == crate::relay::relay_ws_url_with_override(&state) + && state.signing_keys().is_ok(); + let status_head = if status_candidate { + manager.begin_status_attempt().await + } else { + None + }; + let prepared_status = match (on_projection, status_head) { + (Some(channel), Some((generation, attempt))) => tokio::select! { _ = connect_cancel.cancelled() => { return Err("WebSocket connection cancelled".to_string()); } - prepared = prepare_status_session(&state, &url, channel, generation) => prepared, + prepared = prepare_status_session(&state, &url, channel, generation, attempt) => prepared, }, _ => None, }; if prepared_status.is_some() - && manager.status_generation().await + && manager.status_head().await != prepared_status .as_ref() - .map(|prepared| prepared.scope.generation) + .map(|prepared| (prepared.scope.generation, prepared.scope.attempt)) { return Err("WebSocket connection scope changed".to_string()); } @@ -596,6 +791,7 @@ async fn prepare_status_session( requested_url: &str, projection_channel: Channel, generation: u64, + attempt: u64, ) -> Option { if requested_url != crate::relay::relay_ws_url_with_override(state) { return None; @@ -612,6 +808,7 @@ async fn prepare_status_session( epoch, projection_channel, generation, + attempt, challenge: None, auth_proven: false, }, @@ -786,24 +983,7 @@ async fn run_connection_inner( S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { loop { - let expiry_delay = status_session - .as_ref() - .and_then(ClientBindingStatusSession::projected_fresh_until) - .map(|fresh_until| Duration::from_secs(fresh_until.saturating_sub(unix_now()))); - let expiry = async { - match expiry_delay { - Some(delay) => tokio::time::sleep(delay).await, - None => std::future::pending::<()>().await, - } - }; tokio::select! { - _ = expiry => { - if let Some(session) = status_session.as_mut() { - let epoch = session.connection_epoch().clone(); - let update = session.expire(unix_now()); - manager.apply_projection_update(id, &handle, &epoch, update).await; - } - } _ = cancel.cancelled() => { let _ = tokio::time::timeout( SHUTDOWN_TIMEOUT, @@ -838,13 +1018,27 @@ async fn run_connection_inner( if let Some(text) = reserved_text { if let Some(mut session) = status_session.take() { let epoch = session.connection_epoch().clone(); + #[cfg(test)] + let fold_pause = handle.fold_pause.clone(); let folded = tauri::async_runtime::spawn_blocking(move || { + #[cfg(test)] + if let Some(fold_pause) = fold_pause { + fold_pause.block(); + } let update = session.consume_text(&text, unix_now()); (session, update) }) .await; match folded { - Ok((returned_session, update)) => { + Ok((mut returned_session, update)) => { + let update = if matches!( + returned_session.expire(unix_now()), + ProjectionUpdate::Clear + ) { + Some(ProjectionUpdate::Clear) + } else { + update + }; status_session = Some(returned_session); if let Some(update) = update { manager @@ -909,6 +1103,15 @@ fn unix_now() -> u64 { .map_or(0, |duration| duration.as_secs()) } +fn duration_until_unix_second(unix_second: u64) -> Duration { + let Some(deadline) = std::time::UNIX_EPOCH.checked_add(Duration::from_secs(unix_second)) else { + return Duration::ZERO; + }; + deadline + .duration_since(std::time::SystemTime::now()) + .unwrap_or_default() +} + fn outbound_message(message: Message) -> OutboundMessage { match message { Message::Text(value) => OutboundMessage::Text(value.to_string()), @@ -1029,6 +1232,7 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(1, handle.clone()); let task = tauri::async_runtime::spawn(run_connection( @@ -1074,6 +1278,7 @@ mod tests { std::future::pending::<()>().await; }))), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(7, handle); ready_rx.await.unwrap(); @@ -1100,6 +1305,7 @@ mod tests { std::future::pending::<()>().await; }))), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(1, handle); gate.cancel(); @@ -1149,6 +1355,7 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(1, blocked); @@ -1158,6 +1365,7 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(2, healthy); @@ -1215,11 +1423,13 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(status_scope), + fold_pause: None, }) } fn test_status_scope( generation: u64, + attempt: u64, relay: PublicKey, author: PublicKey, epoch: ClientBindingEpoch, @@ -1231,6 +1441,7 @@ mod tests { epoch, projection_channel: silent_channel(), generation, + attempt, challenge: None, auth_proven: false, } @@ -1241,10 +1452,11 @@ mod tests { let manager = WebSocketManager::default(); let relay = nostr::Keys::generate().public_key(); let author = nostr::Keys::generate().public_key(); - let generation = manager.projection_generation().await; + let (generation, old_attempt) = manager.begin_status_attempt().await.unwrap(); let old_epoch = test_epoch(0x11); let old = test_handle(Some(test_status_scope( generation, + old_attempt, relay, author, old_epoch.clone(), @@ -1257,51 +1469,58 @@ mod tests { .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) .await .expect("exact native proof"); - manager - .complete_status_auth(7, &old_proof) - .await - .expect("old socket owns projection"); - - let current = CurrentProjection { - event_author_pubkey: author.to_hex(), - fresh_until: u64::MAX, - connection_epoch: old_epoch.as_str().to_owned(), - }; - manager - .apply_projection_update( - 7, - &old, - &old_epoch, - ProjectionUpdate::Current(current.clone()), - ) - .await; - assert_eq!(manager.projection.lock().await.current, Some(current)); assert!(manager .status_auth_proof(7, "challenge-old", "ws://wrong/", author) .await .is_err()); - assert!(manager.projection.lock().await.current.is_some()); + let (new_generation, new_attempt) = manager.begin_status_attempt().await.unwrap(); + assert_eq!(new_generation, generation); + assert!(new_attempt > old_attempt); let new_epoch = test_epoch(0x22); let new = test_handle(Some(test_status_scope( - generation, + new_generation, + new_attempt, relay, author, new_epoch.clone(), ))); - manager.connections.lock().await.insert(7, new.clone()); + manager.connections.lock().await.insert(8, new.clone()); manager - .record_status_challenge(7, &new, "challenge-new") + .record_status_challenge(8, &new, "challenge-new") .await; let new_proof = manager - .status_auth_proof(7, "challenge-new", "ws://localhost:3000/", author) + .status_auth_proof(8, "challenge-new", "ws://localhost:3000/", author) .await .expect("replacement proof"); manager - .complete_status_auth(7, &new_proof) + .complete_status_auth(8, &new_proof) .await .expect("replacement owns projection"); + + // An older eligible attempt cannot replace a newer owner even when its + // exact proof was captured before the newer attempt completed. + assert!(manager.complete_status_auth(7, &old_proof).await.is_err()); + + let current = CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until: unix_now() + 60, + connection_epoch: new_epoch.as_str().to_owned(), + }; + manager + .apply_projection_update( + 8, + &new, + &new_epoch, + ProjectionUpdate::Current(current.clone()), + ) + .await; + assert_eq!( + manager.projection.lock().await.current, + Some(current.clone()) + ); + manager.clear_projection_if_owner(7, &old, &old_epoch).await; manager .apply_projection_update( @@ -1315,25 +1534,209 @@ mod tests { }), ) .await; - assert!(manager.projection.lock().await.current.is_none()); + assert_eq!(manager.projection.lock().await.current, Some(current)); let read_only = test_handle(None); manager .connections .lock() .await - .insert(8, read_only.clone()); + .insert(9, read_only.clone()); assert!(manager - .status_auth_proof(8, "challenge", "ws://localhost:3000/", author) + .status_auth_proof(9, "challenge", "ws://localhost:3000/", author) .await .is_err()); manager.invalidate_projection().await; assert!(new.cancel.is_cancelled()); assert!(manager.projection.lock().await.owner.is_none()); - assert!(manager.complete_status_auth(7, &new_proof).await.is_err()); + assert!(manager.complete_status_auth(8, &new_proof).await.is_err()); manager.suspend_projection().await; - assert!(manager.status_generation().await.is_none()); + assert!(manager.status_head().await.is_none()); + } + + #[tokio::test] + async fn overlapping_scope_mutations_keep_status_fail_closed() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x33); + let handle = test_handle(Some(test_status_scope( + generation, attempt, relay, author, epoch, + ))); + manager.connections.lock().await.insert(10, handle.clone()); + manager + .record_status_challenge(10, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(10, "challenge", "ws://localhost:3000/", author) + .await + .expect("pre-mutation proof"); + + manager.begin_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + assert!(handle.cancel.is_cancelled()); + manager.begin_scope_mutation().await; + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_some()); + assert!(manager.complete_status_auth(10, &proof).await.is_err()); + } + + #[tokio::test] + async fn security_token_exhaustion_fails_closed() { + let manager = WebSocketManager::default(); + manager.projection.lock().await.attempt_head = u64::MAX; + assert!(manager.begin_status_attempt().await.is_none()); + assert!(manager.projection.lock().await.suspended); + + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x34); + let current = CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: unix_now() + 60, + connection_epoch: epoch.as_str().to_owned(), + }; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 12, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 1, + presentation_token: u64::MAX, + channel: silent_channel(), + }); + projection.current = Some(current.clone()); + } + manager + .apply_projection_update(12, &handle, &epoch, ProjectionUpdate::Current(current)) + .await; + let projection = manager.projection.lock().await; + assert!(projection.owner.is_none()); + assert!(projection.current.is_none()); + } + + #[tokio::test] + async fn projection_expires_while_reserved_fold_is_blocked() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x44); + let pause = Arc::new(TestFoldPause::new()); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(Some(test_status_scope( + generation, + attempt, + relay, + author, + epoch.clone(), + ))), + fold_pause: Some(pause.clone()), + }); + manager.connections.lock().await.insert(11, handle.clone()); + manager + .record_status_challenge(11, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(11, "challenge", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + manager + .complete_status_auth(11, &proof) + .await + .expect("test socket owns projection"); + + let fresh_until = unix_now() + 2; + manager + .apply_projection_update( + 11, + &handle, + &epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_some()); + + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let task = tauri::async_runtime::spawn(run_connection_inner( + 11, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + handle.clone(), + Some(ClientBindingStatusSession::new(relay, author, epoch)), + )); + *handle.task.lock().await = Some(task); + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into(), + )) + .await + .expect("send reserved frame"); + + let entered = tokio::time::timeout(Duration::from_secs(1), async { + while !pause.entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .is_ok(); + let visible_after_entering_fold = manager.projection.lock().await.current.is_some(); + let expired = if entered { + tokio::time::timeout(Duration::from_secs(3), async { + while manager.projection.lock().await.current.is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .is_ok() + } else { + false + }; + + pause.release(); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + let task = handle.task.lock().await.take().expect("registered task"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + + assert!(entered, "reserved fold should reach its blocking section"); + assert!( + visible_after_entering_fold, + "projection should still be visible when the fold blocks" + ); + assert!( + expired, + "deadline must clear while the fold remains blocked" + ); + assert!(unix_now() >= fresh_until); } #[tokio::test] @@ -1345,6 +1748,7 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(None), + fold_pause: None, }); let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); let current = Arc::new(ConnectionHandle { @@ -1352,6 +1756,7 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(9, old.clone()); manager.connections.lock().await.insert(9, current.clone()); @@ -1387,6 +1792,7 @@ mod tests { cancel: CancellationToken::new(), task: Mutex::new(None), status_scope: Mutex::new(None), + fold_pause: None, }); manager.connections.lock().await.insert(42, handle.clone()); let task = tauri::async_runtime::spawn(run_connection( From 4fb09866297099bdfb77081ff457b9cefd385325 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:27:06 -0500 Subject: [PATCH 09/40] fix(desktop): make status expiry monotonic Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src-tauri/src/native_websocket.rs | 36 +++++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 48a43cf2fd..6d79bb9a38 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -377,8 +377,9 @@ impl WebSocketManager { if let Some((id, handle, epoch, attempt, presentation_token, fresh_until)) = expiry { let manager = self.clone(); + let expires_after = duration_until_unix_second(fresh_until); let _ = tauri::async_runtime::spawn(async move { - tokio::time::sleep(duration_until_unix_second(fresh_until)).await; + tokio::time::sleep(expires_after).await; manager .expire_projection_if_owner( id, @@ -412,8 +413,7 @@ impl WebSocketManager { }) && projection .current .as_ref() - .is_some_and(|current| current.fresh_until == fresh_until) - && unix_now() >= fresh_until; + .is_some_and(|current| current.fresh_until == fresh_until); if !matches_current { return; } @@ -1621,6 +1621,36 @@ mod tests { assert!(projection.current.is_none()); } + #[tokio::test] + async fn matching_monotonic_expiry_clears_when_wall_clock_looks_early() { + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x35); + let fresh_until = unix_now() + 60; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 13, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 2, + presentation_token: 3, + channel: silent_channel(), + }); + projection.current = Some(CurrentProjection { + event_author_pubkey: "22".repeat(32), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), + }); + } + + assert!(unix_now() < fresh_until, "test models a backward clock"); + manager + .expire_projection_if_owner(13, &handle, &epoch, 2, 3, fresh_until) + .await; + assert!(manager.projection.lock().await.current.is_none()); + } + #[tokio::test] async fn projection_expires_while_reserved_fold_is_blocked() { let manager = WebSocketManager::default(); From d78d382deff738c60fa201dd7460317ec7248dde Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:34:12 -0500 Subject: [PATCH 10/40] fix(desktop): capture absolute status deadline Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src-tauri/src/native_websocket.rs | 26 +++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 6d79bb9a38..886410856b 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -377,9 +377,9 @@ impl WebSocketManager { if let Some((id, handle, epoch, attempt, presentation_token, fresh_until)) = expiry { let manager = self.clone(); - let expires_after = duration_until_unix_second(fresh_until); + let expires_at = monotonic_deadline_after(duration_until_unix_second(fresh_until)); let _ = tauri::async_runtime::spawn(async move { - tokio::time::sleep(expires_after).await; + status_expiry_sleep(expires_at).await; manager .expire_projection_if_owner( id, @@ -1112,6 +1112,15 @@ fn duration_until_unix_second(unix_second: u64) -> Duration { .unwrap_or_default() } +fn monotonic_deadline_after(delay: Duration) -> tokio::time::Instant { + let now = tokio::time::Instant::now(); + now.checked_add(delay).unwrap_or(now) +} + +fn status_expiry_sleep(deadline: tokio::time::Instant) -> tokio::time::Sleep { + tokio::time::sleep_until(deadline) +} + fn outbound_message(message: Message) -> OutboundMessage { match message { Message::Text(value) => OutboundMessage::Text(value.to_string()), @@ -1651,6 +1660,19 @@ mod tests { assert!(manager.projection.lock().await.current.is_none()); } + #[tokio::test] + async fn status_expiry_sleep_keeps_deadline_across_delayed_first_poll() { + let deadline = monotonic_deadline_after(Duration::from_millis(1)); + tokio::time::sleep(Duration::from_millis(10)).await; + + let sleep = status_expiry_sleep(deadline); + assert_eq!(sleep.deadline(), deadline); + sleep.await; + + let overflow_deadline = monotonic_deadline_after(Duration::MAX); + assert!(overflow_deadline <= tokio::time::Instant::now()); + } + #[tokio::test] async fn projection_expires_while_reserved_fold_is_blocked() { let manager = WebSocketManager::default(); From 2bfd9b1a1a6d10f6b29023d0020ac6adc9da4a0e Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:01:56 -0500 Subject: [PATCH 11/40] fix(desktop): split status websocket command Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../tests/nip_fi_runtime_conformance.rs | 31 +++++++++++++ desktop/src-tauri/build.rs | 8 +++- desktop/src-tauri/src/native_websocket.rs | 45 ++++++++++++++----- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs index 989d9eaeb7..50f7c8c4f4 100644 --- a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -15,6 +15,10 @@ const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs"); const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs"); const READ_ONLY_RELAY_CLIENT: &str = include_str!("../../../desktop/src/shared/api/readOnlyRelayClient.ts"); +const PRIMARY_RELAY_CLIENT: &str = + include_str!("../../../desktop/src/shared/api/relayClientSession.ts"); +const NATIVE_WEBSOCKET: &str = include_str!("../../../desktop/src-tauri/src/native_websocket.rs"); +const DESKTOP_BUILD: &str = include_str!("../../../desktop/src-tauri/build.rs"); const WORKSPACE_COMMAND: &str = include_str!("../../../desktop/src-tauri/src/commands/workspace.rs"); const IDENTITY_COMMAND: &str = include_str!("../../../desktop/src-tauri/src/commands/identity.rs"); @@ -458,6 +462,33 @@ fn auth_bootstrap_precedes_status_and_success_ack() { assert!(!READ_ONLY_RELAY_CLIENT.contains("nativeWebsocketId")); } +#[test] +fn native_status_connect_is_dedicated_and_primary_composition_remains_pending() { + let ordinary = NATIVE_WEBSOCKET + .split("async fn connect(") + .nth(1) + .and_then(|suffix| suffix.split("#[tauri::command]").next()) + .expect("ordinary native connect command exists"); + let status = NATIVE_WEBSOCKET + .split("async fn connect_with_status(") + .nth(1) + .and_then(|suffix| suffix.split("async fn connect_internal(").next()) + .expect("dedicated status connect command exists"); + + assert!(!ordinary.contains("on_projection")); + assert!(status.contains("on_projection: Channel")); + assert!(!status.contains("Option>")); + assert!(NATIVE_WEBSOCKET.contains("connect_with_status,")); + assert!(DESKTOP_BUILD.contains("\"connect_with_status\"")); + + // J0/J2 composition remains HOLD until the primary session opts into the + // dedicated seam and passes its returned native ID through NIP-42 AUTH. + assert!(PRIMARY_RELAY_CLIENT.contains("plugin:websocket|connect")); + assert!(!PRIMARY_RELAY_CLIENT.contains("plugin:websocket|connect_with_status")); + assert!(!PRIMARY_RELAY_CLIENT.contains("onProjection")); + assert!(!PRIMARY_RELAY_CLIENT.contains("nativeWebsocketId")); +} + #[test] fn neutral_verified_evidence_is_exactly_one_and_reachable_in_production() { assert!(TRANSPORT_RUNTIME.contains("trait VerifiedProviderEvidenceResolver")); diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2b997af891..543f799bb0 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -112,7 +112,13 @@ fn main() { tauri_build::Attributes::new().plugin( "websocket", tauri_build::InlinedPlugin::new() - .commands(&["connect", "send", "disconnect", "disconnect_all"]) + .commands(&[ + "connect", + "connect_with_status", + "send", + "disconnect", + "disconnect_all", + ]) .default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands), ), ) diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 886410856b..93cd5fadd0 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -747,12 +747,40 @@ async fn connect( state: tauri::State<'_, AppState>, url: String, on_message: Channel, - on_projection: Option>, _config: Option, +) -> Result { + connect_internal(manager.inner(), state.inner(), url, on_message, None).await +} + +#[tauri::command] +async fn connect_with_status( + manager: tauri::State<'_, WebSocketManager>, + state: tauri::State<'_, AppState>, + url: String, + on_message: Channel, + on_projection: Channel, + _config: Option, +) -> Result { + connect_internal( + manager.inner(), + state.inner(), + url, + on_message, + Some(on_projection), + ) + .await +} + +async fn connect_internal( + manager: &WebSocketManager, + state: &AppState, + url: String, + on_message: Channel, + on_projection: Option>, ) -> Result { let connect_cancel = manager.current_connect_cancel().await; let status_candidate = on_projection.is_some() - && url == crate::relay::relay_ws_url_with_override(&state) + && url == crate::relay::relay_ws_url_with_override(state) && state.signing_keys().is_ok(); let status_head = if status_candidate { manager.begin_status_attempt().await @@ -764,7 +792,7 @@ async fn connect( _ = connect_cancel.cancelled() => { return Err("WebSocket connection cancelled".to_string()); } - prepared = prepare_status_session(&state, &url, channel, generation, attempt) => prepared, + prepared = prepare_status_session(state, &url, channel, generation, attempt) => prepared, }, _ => None, }; @@ -776,14 +804,8 @@ async fn connect( { return Err("WebSocket connection scope changed".to_string()); } - open_connection_with_projection( - manager.inner(), - &url, - on_message, - prepared_status, - connect_cancel, - ) - .await + open_connection_with_projection(manager, &url, on_message, prepared_status, connect_cancel) + .await } async fn prepare_status_session( @@ -1140,6 +1162,7 @@ pub fn init() -> TauriPlugin { tauri::plugin::Builder::new("websocket") .invoke_handler(tauri::generate_handler![ connect, + connect_with_status, send, disconnect, disconnect_all From 2b428c9b90ca1cb0b4ee66c1b31fc972f4892453 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:16:29 -0500 Subject: [PATCH 12/40] feat(desktop): add current binding projection store Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src/app/App.tsx | 2 + .../CurrentProjectionBridge.test.mjs | 172 +++++++++++++++ .../CurrentProjectionBridge.tsx | 146 +++++++++++++ .../currentProjectionStore.test.mjs | 169 +++++++++++++++ .../binding-status/currentProjectionStore.ts | 197 ++++++++++++++++++ .../features/communities/useCommunityInit.ts | 2 + 6 files changed, 688 insertions(+) create mode 100644 desktop/src/features/binding-status/CurrentProjectionBridge.test.mjs create mode 100644 desktop/src/features/binding-status/CurrentProjectionBridge.tsx create mode 100644 desktop/src/features/binding-status/currentProjectionStore.test.mjs create mode 100644 desktop/src/features/binding-status/currentProjectionStore.ts diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 104edcbaaf..c57ede1315 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,6 +20,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; +import { CurrentProjectionBridge } from "@/features/binding-status/CurrentProjectionBridge"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; @@ -547,6 +548,7 @@ function CommunityApp({ if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( + { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); +} + +function makeBridge() { + const listenerReady = deferred(); + const snapshotReady = deferred(); + const applied = []; + let eventHandler; + let loadCalls = 0; + let clearCalls = 0; + let unlistenCalls = 0; + + const cleanup = mountCurrentProjectionBridge({ + listenForProjection(handler) { + eventHandler = handler; + return listenerReady.promise; + }, + loadProjection() { + loadCalls += 1; + return snapshotReady.promise; + }, + applyProjection(candidate) { + applied.push(candidate); + }, + clearProjection() { + clearCalls += 1; + }, + }); + + return { + applied, + cleanup, + emit: (candidate) => eventHandler(candidate), + listenerReady, + snapshotReady, + get loadCalls() { + return loadCalls; + }, + get clearCalls() { + return clearCalls; + }, + get unlistenCalls() { + return unlistenCalls; + }, + registerListener() { + listenerReady.resolve(() => { + unlistenCalls += 1; + }); + }, + }; +} + +test("registers the listener before requesting the native snapshot", async () => { + const bridge = makeBridge(); + assert.equal(bridge.clearCalls, 1, "mount starts fail-closed"); + assert.equal(bridge.loadCalls, 0); + + bridge.registerListener(); + await flushPromises(); + assert.equal(bridge.loadCalls, 1); + + const snapshot = { connectionEpoch: "opaque-snapshot" }; + bridge.snapshotReady.resolve(snapshot); + await flushPromises(); + assert.deepEqual(bridge.applied, [snapshot]); + bridge.cleanup(); +}); + +test("a live event fences a delayed bootstrap snapshot without ordering epochs", async () => { + const bridge = makeBridge(); + bridge.registerListener(); + await flushPromises(); + + const live = { connectionEpoch: "aaa" }; + bridge.emit(live); + bridge.snapshotReady.resolve({ connectionEpoch: "zzz" }); + await flushPromises(); + + assert.deepEqual(bridge.applied, [live]); + bridge.cleanup(); +}); + +test("snapshot failure clears only when no newer live event exists", async () => { + const noEvent = makeBridge(); + noEvent.registerListener(); + await flushPromises(); + noEvent.snapshotReady.reject(new Error("getter unavailable")); + await flushPromises(); + assert.equal(noEvent.clearCalls, 2); + noEvent.cleanup(); + + const newerEvent = makeBridge(); + newerEvent.registerListener(); + await flushPromises(); + const live = { connectionEpoch: "new-live-event" }; + newerEvent.emit(live); + newerEvent.snapshotReady.reject(new Error("stale getter failure")); + await flushPromises(); + assert.deepEqual(newerEvent.applied, [live]); + assert.equal(newerEvent.clearCalls, 1); + newerEvent.cleanup(); +}); + +test("listener setup failure remains fail-closed and skips the getter", async () => { + const bridge = makeBridge(); + bridge.listenerReady.reject(new Error("listen failed")); + await flushPromises(); + + assert.equal(bridge.loadCalls, 0); + assert.equal(bridge.clearCalls, 2); + assert.deepEqual(bridge.applied, []); + bridge.cleanup(); +}); + +test("teardown clears and rejects late listener and snapshot work", async () => { + const beforeRegistration = makeBridge(); + beforeRegistration.cleanup(); + beforeRegistration.registerListener(); + await flushPromises(); + assert.equal(beforeRegistration.clearCalls, 2); + assert.equal(beforeRegistration.unlistenCalls, 1); + assert.equal(beforeRegistration.loadCalls, 0); + + const duringSnapshot = makeBridge(); + duringSnapshot.registerListener(); + await flushPromises(); + duringSnapshot.cleanup(); + duringSnapshot.snapshotReady.resolve({ connectionEpoch: "late" }); + await flushPromises(); + assert.equal(duringSnapshot.clearCalls, 2); + assert.equal(duringSnapshot.unlistenCalls, 1); + assert.deepEqual(duringSnapshot.applied, []); +}); + +test("the bridge accepts an injected revision gate", () => { + let gateCreations = 0; + const listenerReady = deferred(); + const cleanup = mountCurrentProjectionBridge({ + listenForProjection: () => listenerReady.promise, + loadProjection: () => Promise.resolve(null), + applyProjection: () => {}, + clearProjection: () => {}, + createGate(apply, clear) { + gateCreations += 1; + return createCurrentProjectionRevisionGate(apply, clear); + }, + }); + + assert.equal(gateCreations, 1); + cleanup(); + listenerReady.resolve(() => {}); +}); diff --git a/desktop/src/features/binding-status/CurrentProjectionBridge.tsx b/desktop/src/features/binding-status/CurrentProjectionBridge.tsx new file mode 100644 index 0000000000..ec51721a4b --- /dev/null +++ b/desktop/src/features/binding-status/CurrentProjectionBridge.tsx @@ -0,0 +1,146 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useEffect } from "react"; + +import { + applyCurrentProjectionFromNative, + clearCurrentProjection, +} from "./currentProjectionStore"; + +export const CURRENT_PROJECTION_EVENT = "current-binding-projection-changed"; +export const CURRENT_PROJECTION_GETTER = "get_current_binding_projection"; + +type SnapshotFence = { + resolve: (candidate: unknown) => void; + reject: () => void; +}; + +export type CurrentProjectionRevisionGate = { + applyEvent: (candidate: unknown) => void; + beginSnapshot: () => SnapshotFence; + failClosed: () => void; + close: () => void; +}; + +type CurrentProjectionBridgeDependencies = { + listenForProjection: ( + handler: (candidate: unknown) => void, + ) => Promise; + loadProjection: () => Promise; + applyProjection: (candidate: unknown) => void; + clearProjection: () => void; + createGate?: ( + applyProjection: (candidate: unknown) => void, + clearProjection: () => void, + ) => CurrentProjectionRevisionGate; +}; + +/** + * Fence browser-local async work without interpreting the opaque native epoch. + */ +export function createCurrentProjectionRevisionGate( + applyProjection: (candidate: unknown) => void, + clearProjection: () => void, +): CurrentProjectionRevisionGate { + let revision = 0; + let active = true; + + return { + applyEvent(candidate) { + if (!active) return; + revision += 1; + applyProjection(candidate); + }, + beginSnapshot() { + const capturedRevision = revision; + let settled = false; + const claimFence = () => { + if (settled) return false; + settled = true; + return active && revision === capturedRevision; + }; + return { + resolve(candidate) { + if (claimFence()) applyProjection(candidate); + }, + reject() { + if (claimFence()) clearProjection(); + }, + }; + }, + failClosed() { + if (!active) return; + revision += 1; + clearProjection(); + }, + close() { + if (!active) return; + active = false; + revision += 1; + clearProjection(); + }, + }; +} + +const defaultDependencies: CurrentProjectionBridgeDependencies = { + listenForProjection: (handler) => + listen(CURRENT_PROJECTION_EVENT, (event) => { + handler(event.payload); + }), + loadProjection: () => invoke(CURRENT_PROJECTION_GETTER), + applyProjection: applyCurrentProjectionFromNative, + clearProjection: clearCurrentProjection, +}; + +/** + * Register the live listener before reading the native snapshot. The local + * revision fence prevents a delayed bootstrap result from replacing a newer + * event; opaque connection epochs are deliberately never compared in React. + */ +export function mountCurrentProjectionBridge( + dependencies: CurrentProjectionBridgeDependencies = defaultDependencies, +): () => void { + let active = true; + let unlisten: UnlistenFn | null = null; + const gate = (dependencies.createGate ?? createCurrentProjectionRevisionGate)( + dependencies.applyProjection, + dependencies.clearProjection, + ); + + // Mounting must not expose state left by an earlier bridge lifecycle. + dependencies.clearProjection(); + + void dependencies + .listenForProjection((candidate) => gate.applyEvent(candidate)) + .then(async (registeredUnlisten) => { + if (!active) { + registeredUnlisten(); + return; + } + + unlisten = registeredUnlisten; + const snapshotFence = gate.beginSnapshot(); + try { + const candidate = await dependencies.loadProjection(); + snapshotFence.resolve(candidate); + } catch { + snapshotFence.reject(); + } + }) + .catch(() => { + if (active) gate.failClosed(); + }); + + return () => { + if (!active) return; + active = false; + gate.close(); + unlisten?.(); + unlisten = null; + }; +} + +export function CurrentProjectionBridge(): null { + useEffect(() => mountCurrentProjectionBridge(), []); + return null; +} diff --git a/desktop/src/features/binding-status/currentProjectionStore.test.mjs b/desktop/src/features/binding-status/currentProjectionStore.test.mjs new file mode 100644 index 0000000000..e394ddf8dc --- /dev/null +++ b/desktop/src/features/binding-status/currentProjectionStore.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createCurrentProjectionStore, + parseCurrentProjection, +} from "./currentProjectionStore.ts"; + +const AUTHOR_A = "ab".repeat(32); +const AUTHOR_B = "cd".repeat(32); + +function projection(overrides = {}) { + return { + eventAuthorPubkey: AUTHOR_A, + freshUntil: 200, + connectionEpoch: "opaque-epoch-a", + ...overrides, + }; +} + +function makeTimerHost(initialNow = 100) { + let now = initialNow; + let nextId = 1; + const pending = new Map(); + const callbacks = new Map(); + const delays = []; + + return { + options: { + nowSeconds: () => now, + setTimeout: (callback, delayMs) => { + const id = nextId++; + pending.set(id, callback); + callbacks.set(id, callback); + delays.push(delayMs); + return id; + }, + clearTimeout: (id) => pending.delete(id), + }, + setNow: (value) => { + now = value; + }, + fire: (id) => { + pending.delete(id); + callbacks.get(id)?.(); + }, + pendingIds: () => [...pending.keys()], + delays, + }; +} + +test("parses only the frozen narrow DTO and strips native extras", () => { + const parsed = parseCurrentProjection( + projection({ rawEvent: "must-not-cross", revision: 42 }), + 100, + ); + + assert.deepEqual(parsed, projection()); + assert.deepEqual(Object.keys(parsed), [ + "eventAuthorPubkey", + "freshUntil", + "connectionEpoch", + ]); + assert.equal(Object.isFrozen(parsed), true); + assert.throws(() => { + parsed.connectionEpoch = "mutated"; + }, TypeError); +}); + +test("rejects noncanonical authors, invalid deadlines, and empty epochs", () => { + const invalid = [ + null, + [], + projection({ eventAuthorPubkey: AUTHOR_A.toUpperCase() }), + projection({ eventAuthorPubkey: "a".repeat(63) }), + projection({ eventAuthorPubkey: `${"a".repeat(63)}g` }), + projection({ freshUntil: 0 }), + projection({ freshUntil: 100 }), + projection({ freshUntil: 99 }), + projection({ freshUntil: 100.5 }), + projection({ freshUntil: Number.MAX_SAFE_INTEGER + 1 }), + projection({ connectionEpoch: "" }), + ]; + + for (const candidate of invalid) { + assert.equal(parseCurrentProjection(candidate, 100), null); + } + assert.equal(parseCurrentProjection(projection(), Number.NaN), null); +}); + +test("expires at the exclusive deadline without another input", () => { + const timers = makeTimerHost(100.25); + const store = createCurrentProjectionStore(timers.options); + let changes = 0; + store.subscribe(() => { + changes += 1; + }); + + store.replaceFromNative(projection({ freshUntil: 101 })); + assert.equal(store.getSnapshot()?.eventAuthorPubkey, AUTHOR_A); + assert.deepEqual(timers.delays, [750]); + + timers.setNow(101); + timers.fire(timers.pendingIds()[0]); + assert.equal(store.getSnapshot(), null); + assert.equal(changes, 2); +}); + +test("caps long timers and rearms after early fire or clock rollback", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore({ + ...timers.options, + maxTimerDelayMs: 1_000, + }); + + store.replaceFromNative(projection({ freshUntil: 103 })); + assert.deepEqual(timers.delays, [1_000]); + + timers.setNow(99); + timers.fire(timers.pendingIds()[0]); + assert.deepEqual(timers.delays, [1_000, 1_000]); + assert.notEqual(store.getSnapshot(), null); + + timers.setNow(103); + timers.fire(timers.pendingIds()[0]); + assert.equal(store.getSnapshot(), null); +}); + +test("captured tokens reject old timers across replacement and clear", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + let changes = 0; + store.subscribe(() => { + changes += 1; + }); + + store.replaceFromNative(projection({ freshUntil: 110 })); + const oldTimer = timers.pendingIds()[0]; + store.replaceFromNative( + projection({ + eventAuthorPubkey: AUTHOR_B, + freshUntil: 120, + connectionEpoch: "opaque-epoch-b", + }), + ); + const currentTimer = timers.pendingIds()[0]; + + timers.setNow(110); + timers.fire(oldTimer); + assert.equal(store.getSnapshot()?.eventAuthorPubkey, AUTHOR_B); + assert.deepEqual(timers.pendingIds(), [currentTimer]); + + store.clear(); + store.clear(); + timers.setNow(120); + timers.fire(currentTimer); + assert.equal(store.getSnapshot(), null); + assert.equal(changes, 3, "the second clear remains notification-idempotent"); +}); + +test("invalid native input clears a current projection", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + + store.replaceFromNative(projection()); + store.replaceFromNative({ ...projection(), connectionEpoch: "" }); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); +}); diff --git a/desktop/src/features/binding-status/currentProjectionStore.ts b/desktop/src/features/binding-status/currentProjectionStore.ts new file mode 100644 index 0000000000..9c9e1f8b8b --- /dev/null +++ b/desktop/src/features/binding-status/currentProjectionStore.ts @@ -0,0 +1,197 @@ +import * as React from "react"; + +export type CurrentProjection = Readonly<{ + eventAuthorPubkey: string; + freshUntil: number; + connectionEpoch: string; +}>; + +type TimerHandle = ReturnType; + +type CurrentProjectionStoreOptions = { + nowSeconds?: () => number; + setTimeout?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimeout?: (handle: TimerHandle) => void; + maxTimerDelayMs?: number; +}; + +export type CurrentProjectionStore = { + getSnapshot: () => CurrentProjection | null; + subscribe: (listener: () => void) => () => void; + replaceFromNative: (candidate: unknown) => void; + clear: () => void; +}; + +const LOWERCASE_HEX_PUBKEY = /^[0-9a-f]{64}$/; +const DEFAULT_MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** + * Copy the narrow native DTO into a frozen browser-owned value. + * + * Unknown properties are intentionally discarded. Expired projections are + * represented by null; the deadline is exclusive. + */ +export function parseCurrentProjection( + candidate: unknown, + nowSeconds: number, +): CurrentProjection | null { + if ( + candidate === null || + typeof candidate !== "object" || + Array.isArray(candidate) || + !Number.isFinite(nowSeconds) + ) { + return null; + } + + const value = candidate as Record; + const { eventAuthorPubkey, freshUntil, connectionEpoch } = value; + if ( + typeof eventAuthorPubkey !== "string" || + !LOWERCASE_HEX_PUBKEY.test(eventAuthorPubkey) || + typeof freshUntil !== "number" || + !Number.isSafeInteger(freshUntil) || + freshUntil <= 0 || + freshUntil <= nowSeconds || + typeof connectionEpoch !== "string" || + connectionEpoch.length === 0 + ) { + return null; + } + + return Object.freeze({ + eventAuthorPubkey, + freshUntil, + connectionEpoch, + }); +} + +export function createCurrentProjectionStore( + options: CurrentProjectionStoreOptions = {}, +): CurrentProjectionStore { + const nowSeconds = options.nowSeconds ?? (() => Date.now() / 1_000); + const schedule = options.setTimeout ?? globalThis.setTimeout.bind(globalThis); + const cancel = + options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis); + const configuredMaxDelay = options.maxTimerDelayMs; + const maxTimerDelayMs = + typeof configuredMaxDelay === "number" && + Number.isFinite(configuredMaxDelay) && + configuredMaxDelay >= 1 + ? Math.min(Math.floor(configuredMaxDelay), DEFAULT_MAX_TIMER_DELAY_MS) + : DEFAULT_MAX_TIMER_DELAY_MS; + + let snapshot: CurrentProjection | null = null; + let expiryTimer: TimerHandle | null = null; + let workToken = 0; + const listeners = new Set<() => void>(); + + const emitChange = () => { + for (const listener of [...listeners]) listener(); + }; + + const invalidatePendingWork = (): number => { + workToken += 1; + if (expiryTimer !== null) { + cancel(expiryTimer); + expiryTimer = null; + } + return workToken; + }; + + const clear = () => { + // Invalidate even when already clear: a late callback must never be able + // to restore or disturb state after an idempotent boundary reset. + invalidatePendingWork(); + if (snapshot === null) return; + snapshot = null; + emitChange(); + }; + + const armExpiry = (projection: CurrentProjection, capturedToken: number) => { + if (capturedToken !== workToken) return; + + const now = nowSeconds(); + if (!Number.isFinite(now) || now >= projection.freshUntil) { + clear(); + return; + } + + const remainingSeconds = projection.freshUntil - now; + const delayMs = + remainingSeconds >= maxTimerDelayMs / 1_000 + ? maxTimerDelayMs + : Math.max(1, Math.ceil(remainingSeconds * 1_000)); + + let scheduledTimer: TimerHandle; + scheduledTimer = schedule(() => { + if (expiryTimer === scheduledTimer) expiryTimer = null; + if (capturedToken !== workToken) return; + + // Timers are capped and wall clocks can move backwards. Rearm until the + // exclusive Unix-seconds boundary has actually been reached. + const firedAt = nowSeconds(); + if (Number.isFinite(firedAt) && firedAt < projection.freshUntil) { + armExpiry(projection, capturedToken); + return; + } + clear(); + }, delayMs); + expiryTimer = scheduledTimer; + }; + + return { + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + replaceFromNative(candidate) { + const projection = parseCurrentProjection(candidate, nowSeconds()); + const capturedToken = invalidatePendingWork(); + if (projection === null) { + if (snapshot === null) return; + snapshot = null; + emitChange(); + return; + } + + snapshot = projection; + emitChange(); + armExpiry(projection, capturedToken); + }, + clear, + }; +} + +const currentProjectionStore = createCurrentProjectionStore(); + +/** Native bridge sink; browser presentation code should use the hook below. */ +export function applyCurrentProjectionFromNative(candidate: unknown): void { + currentProjectionStore.replaceFromNative(candidate); +} + +export function clearCurrentProjection(): void { + currentProjectionStore.clear(); +} + +/** Community-boundary reset for the module-level, memory-only singleton. */ +export function resetCurrentProjectionStore(): void { + currentProjectionStore.clear(); +} + +export function getCurrentProjectionSnapshot(): CurrentProjection | null { + return currentProjectionStore.getSnapshot(); +} + +export function subscribeToCurrentProjection(listener: () => void): () => void { + return currentProjectionStore.subscribe(listener); +} + +export function useCurrentProjection(): CurrentProjection | null { + return React.useSyncExternalStore( + subscribeToCurrentProjection, + getCurrentProjectionSnapshot, + () => null, + ); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 1bd1e090a7..56e4711c45 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -27,6 +27,7 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { resetCurrentProjectionStore } from "@/features/binding-status/currentProjectionStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -57,6 +58,7 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + resetCurrentProjectionStore(); if (isTauri() && isMacPlatform()) { void clearTrayAgentActivity(); } From 6a38c33d336d1db39127f8f6c84490eccdeea46e Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:12:28 -0500 Subject: [PATCH 13/40] fix(desktop): show current relay binding on messages Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../messages/lib/currentRelayBinding.test.mjs | 31 +++++++++++++++++++ .../messages/lib/currentRelayBinding.ts | 12 +++++++ .../messages/ui/CurrentRelayBindingBadge.tsx | 22 +++++++++++++ .../src/features/messages/ui/MessageRow.tsx | 29 ++++++----------- 4 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 desktop/src/features/messages/lib/currentRelayBinding.test.mjs create mode 100644 desktop/src/features/messages/lib/currentRelayBinding.ts create mode 100644 desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx diff --git a/desktop/src/features/messages/lib/currentRelayBinding.test.mjs b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs new file mode 100644 index 0000000000..db5c00becc --- /dev/null +++ b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { hasCurrentRelayBindingForAuthor } from "./currentRelayBinding.ts"; + +const projection = { + eventAuthorPubkey: "abcdef", +}; + +test("returns false without a current projection", () => { + assert.equal(hasCurrentRelayBindingForAuthor(null, "abcdef"), false); +}); + +test("returns true for the exact event author", () => { + assert.equal(hasCurrentRelayBindingForAuthor(projection, "abcdef"), true); +}); + +test("does not normalize author case", () => { + assert.equal(hasCurrentRelayBindingForAuthor(projection, "ABCDEF"), false); +}); + +test("does not trim author whitespace", () => { + assert.equal(hasCurrentRelayBindingForAuthor(projection, " abcdef"), false); + assert.equal(hasCurrentRelayBindingForAuthor(projection, "abcdef "), false); +}); + +test("returns false for a different or absent event author", () => { + assert.equal(hasCurrentRelayBindingForAuthor(projection, "fedcba"), false); + assert.equal(hasCurrentRelayBindingForAuthor(projection, null), false); + assert.equal(hasCurrentRelayBindingForAuthor(projection, undefined), false); +}); diff --git a/desktop/src/features/messages/lib/currentRelayBinding.ts b/desktop/src/features/messages/lib/currentRelayBinding.ts new file mode 100644 index 0000000000..67c5095269 --- /dev/null +++ b/desktop/src/features/messages/lib/currentRelayBinding.ts @@ -0,0 +1,12 @@ +type CurrentProjectionAuthor = Readonly<{ + eventAuthorPubkey: string; +}>; + +export function hasCurrentRelayBindingForAuthor( + projection: CurrentProjectionAuthor | null, + eventAuthorPubkey: string | null | undefined, +): boolean { + return ( + projection !== null && projection.eventAuthorPubkey === eventAuthorPubkey + ); +} diff --git a/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx b/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx new file mode 100644 index 0000000000..6921f43ca9 --- /dev/null +++ b/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx @@ -0,0 +1,22 @@ +export function CurrentRelayBindingBadge() { + return ( + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4a7bab5441..8c11c4bc46 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -7,15 +7,14 @@ import { reactionsEqual, tagsEqual, } from "@/features/messages/lib/messageRowEquality"; +import { hasCurrentRelayBindingForAuthor } from "@/features/messages/lib/currentRelayBinding"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { useCurrentProjection } from "@/features/binding-status/currentProjectionStore"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; -import { - resolveUserVerification, - type UserProfileLookup, -} from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { @@ -35,7 +34,6 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; @@ -47,6 +45,7 @@ import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; +import { CurrentRelayBindingBadge } from "./CurrentRelayBindingBadge"; import { MessageTimestamp } from "./MessageTimestamp"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -158,6 +157,7 @@ export const MessageRow = React.memo( const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); + const currentProjection = useCurrentProjection(); const handleEntranceAnimationEnd = React.useCallback( (event: React.AnimationEvent) => { if ( @@ -481,9 +481,10 @@ export const MessageRow = React.memo( ) : ( {message.author} ); - const verifiedName = message.pubkey - ? resolveUserVerification({ pubkey: message.pubkey, profiles }) - : null; + const showCurrentRelayBinding = hasCurrentRelayBindingForAuthor( + currentProjection, + message.pubkey, + ); const agentOwnerNode = message.isAgent ? ( - ) : null} + {showCurrentRelayBinding ? : null} {agentOwnerNode} {inlineMetadataNode} {message.personaDisplayName && From ea16112e925a1984649ba32d7bded0f774ee8e97 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:16:59 -0500 Subject: [PATCH 14/40] fix(desktop): retire profile-derived trust presentation Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src/features/profile/hooks.ts | 86 +----------------- .../features/profile/lib/identity.test.mjs | 90 +++++++++++++++---- desktop/src/features/profile/lib/identity.ts | 65 ++------------ .../features/profile/ui/ProfilePopover.tsx | 19 +--- .../features/profile/ui/UserProfilePanel.tsx | 4 +- .../profile/ui/UserProfilePanelFields.tsx | 30 ------- .../profile/ui/UserProfilePanelSections.tsx | 19 ---- .../profile/ui/UserProfilePanelUtils.test.mjs | 41 +++++++++ .../profile/ui/UserProfilePanelUtils.ts | 7 +- .../profile/ui/UserProfilePopover.tsx | 16 +--- .../src/features/sidebar/ui/AppSidebar.tsx | 6 +- .../sidebar/ui/SidebarProfileCard.tsx | 21 ++--- .../shared/hooks/useVerifiedIdentityExpiry.ts | 44 --------- .../src/shared/lib/verifiedIdentity.test.mjs | 51 ----------- desktop/src/shared/lib/verifiedIdentity.ts | 57 ------------ desktop/src/shared/ui/VerifiedBadge.tsx | 55 ------------ 16 files changed, 141 insertions(+), 470 deletions(-) delete mode 100644 desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts delete mode 100644 desktop/src/shared/lib/verifiedIdentity.test.mjs delete mode 100644 desktop/src/shared/lib/verifiedIdentity.ts delete mode 100644 desktop/src/shared/ui/VerifiedBadge.tsx diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 2acb1ec3f4..2a90e56fab 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -46,77 +46,12 @@ import { } from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; -import { useVerifiedIdentityExpiryRevision } from "@/shared/hooks/useVerifiedIdentityExpiry"; -import { - type VerifiedIdentityFields, - withCurrentVerifiedIdentity, -} from "@/shared/lib/verifiedIdentity"; export const profileQueryKey = ["profile"] as const; export const contactListQueryKey = (pubkey: string) => ["contact-list", pubkey] as const; export const allPulseTimelinesQueryKey = ["pulse-timeline"] as const; -function useCurrentVerifiedIdentity( - identity: T | undefined, -): T | undefined { - const revision = useVerifiedIdentityExpiryRevision([ - identity?.verifiedNameExpiresAt, - ]); - return React.useMemo(() => { - // `revision` is the timer-driven cache key for an otherwise unchanged - // React Query value. - void revision; - return identity ? withCurrentVerifiedIdentity(identity) : undefined; - }, [identity, revision]); -} - -function useCurrentVerifiedIdentityRecord( - identities: Record | undefined, -): Record | undefined { - const revision = useVerifiedIdentityExpiryRevision( - identities - ? Object.values(identities).map( - (identity) => identity.verifiedNameExpiresAt, - ) - : [], - ); - return React.useMemo(() => { - void revision; - if (!identities) return undefined; - - let changed = false; - const current = Object.fromEntries( - Object.entries(identities).map(([pubkey, identity]) => { - const next = withCurrentVerifiedIdentity(identity); - changed ||= next !== identity; - return [pubkey, next]; - }), - ); - return changed ? current : identities; - }, [identities, revision]); -} - -function useCurrentVerifiedIdentityList( - identities: T[] | undefined, -): T[] | undefined { - const revision = useVerifiedIdentityExpiryRevision( - identities?.map((identity) => identity.verifiedNameExpiresAt) ?? [], - ); - return React.useMemo(() => { - void revision; - if (!identities) return undefined; - - let changed = false; - const current = identities.map((identity) => { - const next = withCurrentVerifiedIdentity(identity); - changed ||= next !== identity; - return next; - }); - return changed ? current : identities; - }, [identities, revision]); -} - /** * Persists a freshly-fetched profile to localStorage as the offline fallback. * Reuses an existing avatar data URL when the avatar URL is unchanged to avoid @@ -218,8 +153,7 @@ export function useProfileQuery(enabled = true) { staleTime: 30_000, ...seedOptions, }); - const profile = useCurrentVerifiedIdentity(query.data); - return profile === query.data ? query : { ...query, data: profile }; + return query; } /** @@ -347,8 +281,7 @@ export function useUserProfileQuery(pubkey?: string) { queryFn: () => getUserProfile(pubkey), staleTime: 60_000, }); - const profile = useCurrentVerifiedIdentity(query.data); - return profile === query.data ? query : { ...query, data: profile }; + return query; } // Per-pubkey resolution cache backing `useUsersBatchQuery`'s delta fetch. @@ -478,15 +411,7 @@ export function useUsersBatchQuery( } }, [query.data, query.dataUpdatedAt, queryClient]); - const profiles = useCurrentVerifiedIdentityRecord(query.data?.profiles); - return profiles === query.data?.profiles - ? query - : { - ...query, - data: query.data - ? { ...query.data, profiles: profiles ?? {} } - : query.data, - }; + return query; } export function useUserSearchQuery( @@ -510,10 +435,7 @@ export function useUserSearchQuery( staleTime: 30_000, gcTime: 5 * 60 * 1_000, }); - const users = useCurrentVerifiedIdentityList(searchQuery.data); - return users === searchQuery.data - ? searchQuery - : { ...searchQuery, data: users }; + return searchQuery; } export function useInfiniteUserSearchQuery( diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index 126eb195d2..f5f2439ad8 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -3,16 +3,15 @@ import test from "node:test"; import { formatOwnerLabel, - formatVerifiedUserLabel, + formatProfileLabel, profileLookupsEqual, resolveUserLabel, + truncatePubkey, } from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const USER_PUBKEY = "11".repeat(32); -const NOW_MS = 1_800_000_000_000; -const FUTURE_EXPIRATION = NOW_MS / 1_000 + 60; const summary = (over = {}) => ({ displayName: "Ada", @@ -66,8 +65,7 @@ test("profileLookupsEqual: same count, different keys is not equal", () => { test("profileLookupsEqual: a changed field is not equal", () => { for (const field of [ "displayName", - "verifiedName", - "verifiedNameExpiresAt", + "name", "avatarUrl", "nip05Handle", "ownerPubkey", @@ -84,6 +82,26 @@ test("profileLookupsEqual: a changed field is not equal", () => { } }); +test("profileLookupsEqual ignores dormant verified transport fields", () => { + assert.equal( + profileLookupsEqual( + { + p1: summary({ + verifiedName: "old alias", + verifiedNameExpiresAt: 1, + }), + }, + { + p1: summary({ + verifiedName: "new alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + }, + ), + true, + ); +}); + test("profileLookupsEqual: two empty lookups are equal", () => { assert.equal(profileLookupsEqual({}, {}), true); }); @@ -131,39 +149,75 @@ test("stabiliser: a real profile change swaps the reference (re-render fires)", assert.equal(held, changed, "must re-stabilise around the new value"); }); -test("formats a chosen name followed by the authoritative display name", () => { +test("profile labels use display name and ignore verified fields", () => { assert.equal( - formatVerifiedUserLabel("Example", "example", FUTURE_EXPIRATION, NOW_MS), - "Example (example)", + formatProfileLabel({ + displayName: " Example ", + nip05Handle: "example@nip05.test", + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + "Example", ); }); -test("does not duplicate equal chosen and authoritative names", () => { +test("profile labels fall back to NIP-05 and ignore verified fields", () => { assert.equal( - formatVerifiedUserLabel("example", "example", FUTURE_EXPIRATION, NOW_MS), - "example", + formatProfileLabel({ + displayName: " ", + nip05Handle: " example@nip05.test ", + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + "example@nip05.test", ); }); -test("expired authoritative names fail closed", () => { +test("resolved user labels use display name without verified aliases", () => { assert.equal( - formatVerifiedUserLabel("Example", "example", NOW_MS / 1_000, NOW_MS), + resolveUserLabel({ + pubkey: USER_PUBKEY, + fallbackName: "Safe fallback", + profiles: { + [USER_PUBKEY]: summary({ + displayName: "Example", + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + }, + }), "Example", ); }); -test("resolved user labels keep the chosen name first", () => { +test("resolved user labels preserve the safe fallback chain", () => { assert.equal( resolveUserLabel({ pubkey: USER_PUBKEY, + fallbackName: " Safe fallback ", profiles: { [USER_PUBKEY]: summary({ - displayName: "Example", - verifiedName: "example", - verifiedNameExpiresAt: Math.floor(Date.now() / 1_000) + 60, + displayName: null, + nip05Handle: null, + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + }, + }), + "Safe fallback", + ); + assert.equal( + resolveUserLabel({ + pubkey: USER_PUBKEY, + profiles: { + [USER_PUBKEY]: summary({ + displayName: null, + nip05Handle: null, + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, }), }, }), - "Example (example)", + truncatePubkey(USER_PUBKEY), ); }); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 95d9b0aeb6..b419315042 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,42 +1,14 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; export type UserProfileLookup = Record; export { truncatePubkey }; -export function formatVerifiedUserLabel( - chosenName: string | null | undefined, - verifiedName: string | null | undefined, - verifiedNameExpiresAt: number | null | undefined, - nowMs = Date.now(), +export function formatProfileLabel( + profile: Pick | null | undefined, ): string | null { - const chosen = chosenName?.trim(); - const verified = getCurrentVerifiedName( - verifiedName, - verifiedNameExpiresAt, - nowMs, - ); - - if (chosen && verified && chosen !== verified) { - return `${chosen} (${verified})`; - } - - return chosen || verified || null; -} - -export function formatVerifiedProfileLabel( - profile: - | Pick - | null - | undefined, -): string | null { - return formatVerifiedUserLabel( - profile?.displayName, - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); + return profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null; } /** @@ -71,8 +43,6 @@ export function profileLookupsEqual( if ( next === undefined || prev.displayName !== next.displayName || - prev.verifiedName !== next.verifiedName || - prev.verifiedNameExpiresAt !== next.verifiedNameExpiresAt || prev.name !== next.name || prev.avatarUrl !== next.avatarUrl || prev.nip05Handle !== next.nip05Handle || @@ -100,15 +70,7 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick< - Profile, - | "pubkey" - | "displayName" - | "verifiedName" - | "verifiedNameExpiresAt" - | "avatarUrl" - | "nip05Handle" - > + | Pick | null | undefined, ) { @@ -120,8 +82,6 @@ export function mergeCurrentProfileIntoLookup( ...(profiles ?? {}), [normalizePubkey(currentProfile.pubkey)]: { displayName: currentProfile.displayName, - verifiedName: currentProfile.verifiedName ?? null, - verifiedNameExpiresAt: currentProfile.verifiedNameExpiresAt ?? null, // `Profile` does not carry the kind-0 `name`; keep whatever the batch // lookup already resolved so mention aliases survive the merge. name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null, @@ -162,11 +122,7 @@ export function resolveUserLabel(input: { const displayName = profile?.displayName?.trim(); const nip05Handle = profile?.nip05Handle?.trim(); const safeFallback = fallbackName?.trim(); - const label = formatVerifiedUserLabel( - displayName || nip05Handle || safeFallback, - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); + const label = displayName || nip05Handle || safeFallback; if (label) { return label; } @@ -174,17 +130,6 @@ export function resolveUserLabel(input: { return truncatePubkey(pubkey); } -export function resolveUserVerification(input: { - pubkey: string; - profiles?: UserProfileLookup; -}): string | null { - const profile = getResolvedProfile(input.pubkey, input.profiles); - return getCurrentVerifiedName( - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); -} - /** * Returns true when the current user owns the agent that authored a message. * Mirrors the relay's `is_agent_owner` gate: ownership is determined by the diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 456ea42a31..d70b05fc98 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -17,14 +17,11 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { isMacPlatform } from "@/shared/lib/platform"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; interface ProfilePopoverProps { open: boolean; onOpenChange: (open: boolean) => void; displayName: string; - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; avatarUrl: string | null; avatarDataUrl?: string | null; currentStatus: PresenceStatus; @@ -55,8 +52,6 @@ export function ProfilePopover({ open, onOpenChange, displayName, - verifiedName, - verifiedNameExpiresAt, avatarUrl, avatarDataUrl, currentStatus, @@ -144,17 +139,9 @@ export function ProfilePopover({ />
-
-

- {displayName} -

- {verifiedName ? ( - - ) : null} -
+

+ {displayName} +

{/* ── Presence chip (opens status chooser) ─────────── */} = { goose: "Goose", @@ -50,7 +47,6 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", - "Relay-verified identity", "Managed by", "NIP-05", "Agent type", @@ -178,28 +174,6 @@ export function buildPublicFields({ }); } - const verifiedName = getCurrentVerifiedName( - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); - if (verifiedName) { - fields.push({ - displayValue: verifiedName, - icon: BadgeCheck, - label: "Relay-verified identity", - testId: "user-profile-relay-verified-identity", - trailingNode: ( - - - Binding active - - ), - }); - } - if (profile?.nip05Handle) { fields.push({ copyValue: profile.nip05Handle, @@ -437,19 +411,16 @@ export function buildOwnerFields({ function orderProfileFields(fields: ProfileField[]) { const visibilityLabel = "Visibility"; const publicKeyLabel = "Public key"; - const relayVerifiedIdentityLabel = "Relay-verified identity"; const managedByLabel = "Managed by"; const statusLabel = "Status"; return [ ...fields.filter((field) => field.label === visibilityLabel), ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === relayVerifiedIdentityLabel), ...fields.filter((field) => field.label === managedByLabel), ...fields.filter( (field) => field.label !== visibilityLabel && field.label !== publicKeyLabel && - field.label !== relayVerifiedIdentityLabel && field.label !== managedByLabel && field.copyValue, ), @@ -458,7 +429,6 @@ function orderProfileFields(fields: ProfileField[]) { if ( field.label === visibilityLabel || field.label === publicKeyLabel || - field.label === relayVerifiedIdentityLabel || field.label === managedByLabel || field.label === statusLabel ) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index a2a3fcac8b..fa60319bdb 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -51,8 +51,6 @@ import type { import { cn } from "@/shared/lib/cn"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; @@ -489,10 +487,6 @@ function ProfileHero({ userStatus: ProfileSummaryViewProps["userStatus"]; }) { const presenceDotClassName = isBot ? "h-4.5 w-4.5" : "h-3.5 w-3.5"; - const verifiedName = getCurrentVerifiedName( - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); return (
@@ -543,19 +537,6 @@ function ProfileHero({ ) : null}
- {verifiedName ? ( -
- {verifiedName} - -
- ) : null} - {profile?.about?.trim() ? ( { + const pubkey = "12".repeat(32); + const baseProfile = { + pubkey, + displayName: null, + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + avatarUrl: null, + about: null, + nip05Handle: "user@nip05.test", + ownerPubkey: null, + hasProfileEvent: true, + }; + + assert.equal( + resolveProfileDisplayName({ + profile: { ...baseProfile, displayName: "Profile name" }, + persona: persona({ displayName: "Safe persona fallback" }), + pubkey, + }), + "Profile name", + ); + assert.equal( + resolveProfileDisplayName({ + profile: baseProfile, + persona: persona({ displayName: "Safe persona fallback" }), + pubkey, + }), + "user@nip05.test", + ); + assert.equal( + resolveProfileDisplayName({ + profile: { ...baseProfile, nip05Handle: null }, + persona: persona({ displayName: "Safe persona fallback" }), + pubkey, + }), + "Safe persona fallback", + ); +}); + test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { assert.equal( personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..afb5450405 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -229,8 +229,9 @@ export function resolveProfileDisplayName({ pubkey: string | null; }) { return ( - profile?.displayName ?? - persona?.displayName ?? + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + persona?.displayName?.trim() || (pubkey ? truncatePubkey(pubkey) : "Agent") ); } @@ -244,8 +245,8 @@ export function resolveOwnerHandle( } return ( - profile?.nip05Handle?.trim() || profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || truncatePubkey(currentPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d2b4b1bc22..930e1ce66d 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -25,7 +25,7 @@ import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { - formatVerifiedUserLabel, + formatProfileLabel, formatOwnerLabel, ownsAuthorAgent, } from "@/features/profile/lib/identity"; @@ -52,7 +52,6 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -236,12 +235,7 @@ export function UserProfilePopover({ relayAgentsQuery.isPending || managedAgentsQuery.isPending || usersBatchQuery.isPending); - const displayName = - formatVerifiedUserLabel( - profile?.displayName, - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ) ?? truncatePubkey(pubkey); + const displayName = formatProfileLabel(profile) ?? truncatePubkey(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone // wrongly hides the affordance from them — and gating on bot-ness alone shows @@ -535,12 +529,6 @@ export function UserProfilePopover({
- {profile?.verifiedName ? ( - - ) : null} {isBotProfile && botIdenticonValue ? ( @@ -505,7 +505,7 @@ export function AppSidebar({ streamChannels, }); const resolvedDisplayName = - formatVerifiedProfileLabel(profile) || + formatProfileLabel(profile) || fallbackDisplayName?.trim() || "Current identity"; const isCreatingAny = diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 46ba9608e6..e2d9e37620 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -16,7 +16,6 @@ import { useMyRelayMembershipLookupQuery } from "@/features/community-members/ho import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type SidebarProfileCardProps = { activeCommunity: Community | null; @@ -153,8 +152,6 @@ export function SidebarProfileCard({ avatarUrl={profile?.avatarUrl ?? null} currentStatus={selfPresenceStatus} displayName={resolvedDisplayName} - verifiedName={profile?.verifiedName} - verifiedNameExpiresAt={profile?.verifiedNameExpiresAt} isStatusPending={isPresencePending} onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} @@ -193,19 +190,11 @@ export function SidebarProfileCard({ data-testid="open-settings" type="button" > - - - {resolvedDisplayName} - - {profile?.verifiedName ? ( - - ) : null} + + {resolvedDisplayName} diff --git a/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts b/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts deleted file mode 100644 index 4012f6fc5d..0000000000 --- a/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react"; - -import { millisecondsUntilVerifiedIdentityExpiry } from "@/shared/lib/verifiedIdentity"; - -const MAX_TIMEOUT_MS = 2_147_483_647; - -/** - * Force a render at the earliest assertion cutoff. Callers then re-run the - * local-clock sanitizer, even when React Query is serving an offline cache. - */ -export function useVerifiedIdentityExpiryRevision( - expirations: ReadonlyArray, -): number { - const nowMs = Date.now(); - let nextDelayMs: number | null = null; - for (const expiresAt of expirations) { - const delayMs = millisecondsUntilVerifiedIdentityExpiry(expiresAt, nowMs); - if ( - delayMs !== null && - delayMs > 0 && - (nextDelayMs === null || delayMs < nextDelayMs) - ) { - nextDelayMs = delayMs; - } - } - - const [revision, setRevision] = React.useState(0); - React.useEffect(() => { - // Re-arm after a timer tick even if a wall-clock adjustment happens to - // produce the same remaining delay as the previous render. - void revision; - if (nextDelayMs === null) { - return; - } - - const timeout = setTimeout( - () => setRevision((current) => current + 1), - Math.min(nextDelayMs + 1, MAX_TIMEOUT_MS), - ); - return () => clearTimeout(timeout); - }, [nextDelayMs, revision]); - - return revision; -} diff --git a/desktop/src/shared/lib/verifiedIdentity.test.mjs b/desktop/src/shared/lib/verifiedIdentity.test.mjs deleted file mode 100644 index db640aa8d9..0000000000 --- a/desktop/src/shared/lib/verifiedIdentity.test.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - getCurrentVerifiedName, - millisecondsUntilVerifiedIdentityExpiry, - withCurrentVerifiedIdentity, -} from "./verifiedIdentity.ts"; - -const NOW_MS = 1_800_000_000_000; -const NOW_SECONDS = NOW_MS / 1_000; - -test("returns a verified name only before its local expiration", () => { - assert.equal( - getCurrentVerifiedName(" Example ", NOW_SECONDS + 60, NOW_MS), - "Example", - ); - assert.equal(getCurrentVerifiedName("Example", NOW_SECONDS, NOW_MS), null); -}); - -test("missing and malformed expirations fail closed", () => { - assert.equal(getCurrentVerifiedName("Example", null, NOW_MS), null); - assert.equal( - getCurrentVerifiedName("Example", NOW_SECONDS + 0.5, NOW_MS), - null, - ); - assert.equal(getCurrentVerifiedName("Example", Number.NaN, NOW_MS), null); -}); - -test("computes the exact cutoff delay used by expiry render timers", () => { - assert.equal( - millisecondsUntilVerifiedIdentityExpiry(NOW_SECONDS + 60, NOW_MS), - 60_000, - ); - assert.equal( - millisecondsUntilVerifiedIdentityExpiry(NOW_SECONDS - 1, NOW_MS), - 0, - ); -}); - -test("sanitizes cached identity objects without churning valid values", () => { - const valid = { - verifiedName: "Example", - verifiedNameExpiresAt: NOW_SECONDS + 60, - }; - assert.equal(withCurrentVerifiedIdentity(valid, NOW_MS), valid); - assert.deepEqual(withCurrentVerifiedIdentity(valid, NOW_MS + 60_000), { - verifiedName: null, - verifiedNameExpiresAt: NOW_SECONDS + 60, - }); -}); diff --git a/desktop/src/shared/lib/verifiedIdentity.ts b/desktop/src/shared/lib/verifiedIdentity.ts deleted file mode 100644 index 2c0312d7a3..0000000000 --- a/desktop/src/shared/lib/verifiedIdentity.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type VerifiedIdentityFields = { - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; -}; - -function verifiedIdentityExpiryMs( - expiresAt: number | null | undefined, -): number | null { - if (!Number.isSafeInteger(expiresAt) || (expiresAt ?? 0) <= 0) { - return null; - } - - const expiresAtMs = (expiresAt as number) * 1_000; - return Number.isSafeInteger(expiresAtMs) ? expiresAtMs : null; -} - -/** - * Return a verified name only while its relay assertion is still valid. - * Missing or malformed expirations fail closed so old cached responses cannot - * keep a trust label alive while the relay is unreachable. - */ -export function getCurrentVerifiedName( - verifiedName: string | null | undefined, - expiresAt: number | null | undefined, - nowMs = Date.now(), -): string | null { - const name = verifiedName?.trim(); - const expiresAtMs = verifiedIdentityExpiryMs(expiresAt); - if (!name || expiresAtMs === null || expiresAtMs <= nowMs) { - return null; - } - - return name; -} - -export function millisecondsUntilVerifiedIdentityExpiry( - expiresAt: number | null | undefined, - nowMs = Date.now(), -): number | null { - const expiresAtMs = verifiedIdentityExpiryMs(expiresAt); - return expiresAtMs === null ? null : Math.max(0, expiresAtMs - nowMs); -} - -/** Preserve object identity until the verified-name view actually changes. */ -export function withCurrentVerifiedIdentity( - identity: T, - nowMs = Date.now(), -): T { - const verifiedName = getCurrentVerifiedName( - identity.verifiedName, - identity.verifiedNameExpiresAt, - nowMs, - ); - return identity.verifiedName === verifiedName - ? identity - : { ...identity, verifiedName }; -} diff --git a/desktop/src/shared/ui/VerifiedBadge.tsx b/desktop/src/shared/ui/VerifiedBadge.tsx deleted file mode 100644 index 359639ac0f..0000000000 --- a/desktop/src/shared/ui/VerifiedBadge.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useVerifiedIdentityExpiryRevision } from "@/shared/hooks/useVerifiedIdentityExpiry"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; - -export function VerifiedBadge({ - verifiedName, - verifiedNameExpiresAt, -}: { - verifiedName: string; - verifiedNameExpiresAt: number | null | undefined; -}) { - useVerifiedIdentityExpiryRevision([verifiedNameExpiresAt]); - const currentVerifiedName = getCurrentVerifiedName( - verifiedName, - verifiedNameExpiresAt, - ); - if (!currentVerifiedName) { - return null; - } - - return ( - - - - - - - -

Verified as {currentVerifiedName}

-
-
- ); -} From cb12aecad8fbbb3592d2a0646cde1f808f9db3be Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:23:59 -0500 Subject: [PATCH 15/40] test(desktop): cover current relay binding projection Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../tests/e2e/current-binding-status.spec.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 desktop/tests/e2e/current-binding-status.spec.ts diff --git a/desktop/tests/e2e/current-binding-status.spec.ts b/desktop/tests/e2e/current-binding-status.spec.ts new file mode 100644 index 0000000000..30f436c362 --- /dev/null +++ b/desktop/tests/e2e/current-binding-status.spec.ts @@ -0,0 +1,171 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const CURRENT_PROJECTION_EVENT = "current-binding-projection-changed"; +const MATCHING_MESSAGE = "Current binding belongs to this exact author."; +const OTHER_MESSAGE = "Current binding must not decorate this author."; +const LEGACY_ALIAS = "legacy-relay-alias-must-stay-hidden"; +const OPAQUE_EPOCH = "opaque-epoch-must-stay-hidden"; + +async function waitForMockLiveSubscription(page: Page) { + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? false, + ), + ) + .toBe(true); +} + +async function waitForProjectionBridgeBootstrap(page: Page) { + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_COMMANDS__?.includes( + "get_current_binding_projection", + ), + ), + ) + .toBe(true); + + // Let the unsupported mock snapshot reject and fail closed before sending a + // newer live event through the production listener/store path. + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }), + ); +} + +async function emitProjection(page: Page, payload: unknown) { + await page.evaluate( + async ({ event, value }) => { + const emit = window.__BUZZ_E2E_EMIT_TAURI_EVENT__; + if (!emit) throw new Error("Mock Tauri event bridge is not installed."); + await emit(event, value); + }, + { event: CURRENT_PROJECTION_EVENT, value: payload }, + ); +} + +test("current relay binding is exact-author, generic, clearable, and passively expiring", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page); + await waitForProjectionBridgeBootstrap(page); + + const createdAt = Math.floor(Date.now() / 1_000); + await page.evaluate( + ({ matchingMessage, otherMessage, matchingPubkey, otherPubkey, time }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock live-message bridge is not installed."); + + emit({ + channelName: "general", + content: otherMessage, + createdAt: time, + pubkey: otherPubkey, + }); + emit({ + channelName: "general", + content: matchingMessage, + createdAt: time + 1, + pubkey: matchingPubkey, + }); + }, + { + matchingMessage: MATCHING_MESSAGE, + matchingPubkey: TEST_IDENTITIES.bob.pubkey, + otherMessage: OTHER_MESSAGE, + otherPubkey: TEST_IDENTITIES.charlie.pubkey, + time: createdAt, + }, + ); + + const matchingRow = page + .getByTestId("message-row") + .filter({ hasText: MATCHING_MESSAGE }); + const otherRow = page + .getByTestId("message-row") + .filter({ hasText: OTHER_MESSAGE }); + await expect(matchingRow).toBeVisible(); + await expect(otherRow).toBeVisible(); + await expect(matchingRow.getByTestId("message-author")).toBeVisible(); + await expect(otherRow.getByTestId("message-author")).toBeVisible(); + + const freshUntil = Math.floor(Date.now() / 1_000) + 30; + await emitProjection(page, { + connectionEpoch: OPAQUE_EPOCH, + eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, + freshUntil, + name: LEGACY_ALIAS, + verifiedName: LEGACY_ALIAS, + }); + + const badge = matchingRow.getByTestId("current-relay-binding"); + await expect(badge).toHaveCount(1); + await expect(badge).toHaveAccessibleName("Current relay binding"); + await expect(otherRow.getByTestId("current-relay-binding")).toHaveCount(0); + await expect(page.getByTestId("current-relay-binding")).toHaveCount(1); + + const badgeMarkup = ( + await badge.evaluate((element) => element.outerHTML) + ).toLowerCase(); + for (const hiddenValue of [ + TEST_IDENTITIES.bob.pubkey, + String(freshUntil), + OPAQUE_EPOCH, + LEGACY_ALIAS, + "eventauthorpubkey", + "freshuntil", + "connectionepoch", + "verifiedname", + ]) { + expect(badgeMarkup).not.toContain(hiddenValue.toLowerCase()); + } + await expect( + matchingRow.getByRole("img", { exact: true, name: LEGACY_ALIAS }), + ).toHaveCount(0); + + for (const row of [matchingRow, otherRow]) { + await expect(row.getByTestId("relay-verified-identity")).toHaveCount(0); + await expect( + row.locator('[aria-label^="Relay-verified identity"]'), + ).toHaveCount(0); + await expect(row).not.toContainText("Relay-verified identity"); + await expect(row).not.toContainText("Verified as"); + await expect(row).not.toContainText(LEGACY_ALIAS); + } + + await emitProjection(page, null); + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); + + const expiringFreshUntil = Math.floor(Date.now() / 1_000) + 4; + await emitProjection(page, { + connectionEpoch: "opaque-expiring-epoch", + eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, + freshUntil: expiringFreshUntil, + }); + await expect(badge).toBeVisible(); + expect(await page.evaluate(() => Date.now() / 1_000)).toBeLessThan( + expiringFreshUntil, + ); + + // No further event, navigation, or message update: the store's timer must + // clear the projection when the exclusive freshUntil boundary is reached. + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0, { + timeout: 7_000, + }); + expect(await page.evaluate(() => Date.now() / 1_000)).toBeGreaterThanOrEqual( + expiringFreshUntil, + ); +}); From e2d609390ccac21a59bd5f0fd2d08e7fd692ba3a Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:25:03 -0500 Subject: [PATCH 16/40] fix(desktop): fail closed on projection store errors Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../currentProjectionStore.test.mjs | 79 +++++++++++++++++++ .../binding-status/currentProjectionStore.ts | 74 ++++++++++++----- 2 files changed, 134 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/binding-status/currentProjectionStore.test.mjs b/desktop/src/features/binding-status/currentProjectionStore.test.mjs index e394ddf8dc..f9b083c075 100644 --- a/desktop/src/features/binding-status/currentProjectionStore.test.mjs +++ b/desktop/src/features/binding-status/currentProjectionStore.test.mjs @@ -24,11 +24,16 @@ function makeTimerHost(initialNow = 100) { const pending = new Map(); const callbacks = new Map(); const delays = []; + let schedulesToThrow = 0; return { options: { nowSeconds: () => now, setTimeout: (callback, delayMs) => { + if (schedulesToThrow > 0) { + schedulesToThrow -= 1; + throw new Error("synthetic scheduler failure"); + } const id = nextId++; pending.set(id, callback); callbacks.set(id, callback); @@ -45,6 +50,9 @@ function makeTimerHost(initialNow = 100) { callbacks.get(id)?.(); }, pendingIds: () => [...pending.keys()], + throwNextSchedules: (count = 1) => { + schedulesToThrow = count; + }, delays, }; } @@ -167,3 +175,74 @@ test("invalid native input clears a current projection", () => { assert.equal(store.getSnapshot(), null); assert.deepEqual(timers.pendingIds(), []); }); + +test("a throwing subscriber cannot abort expiry or later subscribers", () => { + const timers = makeTimerHost(100); + const logCalls = []; + const store = createCurrentProjectionStore({ + ...timers.options, + onListenerError: (...args) => logCalls.push(args), + }); + const observed = []; + + store.subscribe(() => { + assert.equal( + timers.pendingIds().length, + store.getSnapshot() === null ? 0 : 1, + "expiry is armed before a current snapshot is announced", + ); + throw new Error("synthetic subscriber failure"); + }); + store.subscribe(() => observed.push(store.getSnapshot())); + + store.replaceFromNative(projection({ freshUntil: 101 })); + assert.equal(store.getSnapshot()?.eventAuthorPubkey, AUTHOR_A); + assert.equal(observed.length, 1); + assert.deepEqual(logCalls, [[]], "logging receives no DTO or thrown value"); + + timers.setNow(101); + timers.fire(timers.pendingIds()[0]); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(observed, [projection({ freshUntil: 101 }), null]); + assert.deepEqual(logCalls, [[], []]); +}); + +test("initial and replacement scheduler failures leave the store null", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + + timers.throwNextSchedules(); + store.replaceFromNative(projection({ freshUntil: 110 })); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); + + store.replaceFromNative(projection({ freshUntil: 110 })); + assert.notEqual(store.getSnapshot(), null); + timers.throwNextSchedules(); + store.replaceFromNative( + projection({ + eventAuthorPubkey: AUTHOR_B, + freshUntil: 120, + connectionEpoch: "opaque-epoch-b", + }), + ); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); +}); + +test("rearm scheduler failure clears the current projection", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore({ + ...timers.options, + maxTimerDelayMs: 1_000, + }); + + store.replaceFromNative(projection({ freshUntil: 103 })); + assert.notEqual(store.getSnapshot(), null); + timers.throwNextSchedules(); + timers.setNow(100.5); + timers.fire(timers.pendingIds()[0]); + + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); +}); diff --git a/desktop/src/features/binding-status/currentProjectionStore.ts b/desktop/src/features/binding-status/currentProjectionStore.ts index 9c9e1f8b8b..7326249cfd 100644 --- a/desktop/src/features/binding-status/currentProjectionStore.ts +++ b/desktop/src/features/binding-status/currentProjectionStore.ts @@ -13,6 +13,7 @@ type CurrentProjectionStoreOptions = { setTimeout?: (callback: () => void, delayMs: number) => TimerHandle; clearTimeout?: (handle: TimerHandle) => void; maxTimerDelayMs?: number; + onListenerError?: () => void; }; export type CurrentProjectionStore = { @@ -25,6 +26,12 @@ export type CurrentProjectionStore = { const LOWERCASE_HEX_PUBKEY = /^[0-9a-f]{64}$/; const DEFAULT_MAX_TIMER_DELAY_MS = 2_147_483_647; +function logListenerError(): void { + // Do not include the exception or current DTO: either could contain native + // payload data outside the browser projection contract. + console.error("[currentProjectionStore] subscriber failed"); +} + /** * Copy the narrow native DTO into a frozen browser-owned value. * @@ -73,6 +80,7 @@ export function createCurrentProjectionStore( const schedule = options.setTimeout ?? globalThis.setTimeout.bind(globalThis); const cancel = options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis); + const onListenerError = options.onListenerError ?? logListenerError; const configuredMaxDelay = options.maxTimerDelayMs; const maxTimerDelayMs = typeof configuredMaxDelay === "number" && @@ -87,7 +95,17 @@ export function createCurrentProjectionStore( const listeners = new Set<() => void>(); const emitChange = () => { - for (const listener of [...listeners]) listener(); + for (const listener of [...listeners]) { + try { + listener(); + } catch { + try { + onListenerError(); + } catch { + // Logging is best-effort and must not break the state transition. + } + } + } }; const invalidatePendingWork = (): number => { @@ -108,13 +126,15 @@ export function createCurrentProjectionStore( emitChange(); }; - const armExpiry = (projection: CurrentProjection, capturedToken: number) => { - if (capturedToken !== workToken) return; + const armExpiry = ( + projection: CurrentProjection, + capturedToken: number, + ): boolean => { + if (capturedToken !== workToken) return false; const now = nowSeconds(); if (!Number.isFinite(now) || now >= projection.freshUntil) { - clear(); - return; + return false; } const remainingSeconds = projection.freshUntil - now; @@ -124,20 +144,30 @@ export function createCurrentProjectionStore( : Math.max(1, Math.ceil(remainingSeconds * 1_000)); let scheduledTimer: TimerHandle; - scheduledTimer = schedule(() => { - if (expiryTimer === scheduledTimer) expiryTimer = null; - if (capturedToken !== workToken) return; - - // Timers are capped and wall clocks can move backwards. Rearm until the - // exclusive Unix-seconds boundary has actually been reached. - const firedAt = nowSeconds(); - if (Number.isFinite(firedAt) && firedAt < projection.freshUntil) { - armExpiry(projection, capturedToken); - return; - } - clear(); - }, delayMs); + try { + scheduledTimer = schedule(() => { + if (expiryTimer === scheduledTimer) expiryTimer = null; + if (capturedToken !== workToken) return; + + // Timers are capped and wall clocks can move backwards. Rearm until + // the exclusive Unix-seconds boundary has actually been reached. + const firedAt = nowSeconds(); + if (Number.isFinite(firedAt) && firedAt < projection.freshUntil) { + if ( + !armExpiry(projection, capturedToken) && + capturedToken === workToken + ) { + clear(); + } + return; + } + clear(); + }, delayMs); + } catch { + return false; + } expiryTimer = scheduledTimer; + return true; }; return { @@ -156,9 +186,15 @@ export function createCurrentProjectionStore( return; } + // Arm passive expiry before making the projection observable. A timer + // host failure therefore cannot leave a current snapshot without an + // expiry path. + if (!armExpiry(projection, capturedToken)) { + if (capturedToken === workToken) clear(); + return; + } snapshot = projection; emitChange(); - armExpiry(projection, capturedToken); }, clear, }; From 49a9a9cf6e0007255982a7706bd4255fad900ee0 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:25:47 -0500 Subject: [PATCH 17/40] fix(desktop): bind message badge to event signer Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../messages/lib/currentRelayBinding.test.mjs | 71 +++++++++++++++++-- .../src/features/messages/ui/MessageRow.tsx | 2 +- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/messages/lib/currentRelayBinding.test.mjs b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs index db5c00becc..12ffc22839 100644 --- a/desktop/src/features/messages/lib/currentRelayBinding.test.mjs +++ b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs @@ -3,29 +3,86 @@ import test from "node:test"; import { hasCurrentRelayBindingForAuthor } from "./currentRelayBinding.ts"; +const DISPLAYED_ACTOR_PUBKEY = "a".repeat(64); +const EVENT_SIGNER_PUBKEY = "b".repeat(64); +const OTHER_PUBKEY = "c".repeat(64); + const projection = { - eventAuthorPubkey: "abcdef", + eventAuthorPubkey: EVENT_SIGNER_PUBKEY, }; test("returns false without a current projection", () => { - assert.equal(hasCurrentRelayBindingForAuthor(null, "abcdef"), false); + assert.equal( + hasCurrentRelayBindingForAuthor(null, EVENT_SIGNER_PUBKEY), + false, + ); }); test("returns true for the exact event author", () => { - assert.equal(hasCurrentRelayBindingForAuthor(projection, "abcdef"), true); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, EVENT_SIGNER_PUBKEY), + true, + ); }); test("does not normalize author case", () => { - assert.equal(hasCurrentRelayBindingForAuthor(projection, "ABCDEF"), false); + assert.equal( + hasCurrentRelayBindingForAuthor( + projection, + EVENT_SIGNER_PUBKEY.toUpperCase(), + ), + false, + ); }); test("does not trim author whitespace", () => { - assert.equal(hasCurrentRelayBindingForAuthor(projection, " abcdef"), false); - assert.equal(hasCurrentRelayBindingForAuthor(projection, "abcdef "), false); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, ` ${EVENT_SIGNER_PUBKEY}`), + false, + ); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, `${EVENT_SIGNER_PUBKEY} `), + false, + ); }); test("returns false for a different or absent event author", () => { - assert.equal(hasCurrentRelayBindingForAuthor(projection, "fedcba"), false); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, OTHER_PUBKEY), + false, + ); assert.equal(hasCurrentRelayBindingForAuthor(projection, null), false); assert.equal(hasCurrentRelayBindingForAuthor(projection, undefined), false); }); + +test("uses the raw event signer instead of a relay-attributed display actor", () => { + const relayAttributedMessage = { + pubkey: DISPLAYED_ACTOR_PUBKEY, + signerPubkey: EVENT_SIGNER_PUBKEY, + }; + const displayedActorProjection = { + eventAuthorPubkey: DISPLAYED_ACTOR_PUBKEY, + }; + + assert.equal( + relayAttributedMessage.pubkey, + displayedActorProjection.eventAuthorPubkey, + "the displayed actor matches the projection in this regression case", + ); + assert.equal( + hasCurrentRelayBindingForAuthor( + displayedActorProjection, + relayAttributedMessage.signerPubkey, + ), + false, + "a displayed-actor match must not badge an event signed by another key", + ); + assert.equal( + hasCurrentRelayBindingForAuthor( + projection, + relayAttributedMessage.signerPubkey, + ), + true, + "an exact raw event-signer match may display the badge", + ); +}); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 8c11c4bc46..26fe7a546f 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -483,7 +483,7 @@ export const MessageRow = React.memo( ); const showCurrentRelayBinding = hasCurrentRelayBindingForAuthor( currentProjection, - message.pubkey, + message.signerPubkey, ); const agentOwnerNode = message.isAgent ? ( Date: Wed, 5 Aug 2026 15:28:08 -0500 Subject: [PATCH 18/40] fix(desktop): avoid duplicate NIP-05 labels Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src/features/profile/lib/identity.test.mjs | 13 +++++++++++++ desktop/src/features/profile/lib/identity.ts | 9 +++++++++ .../profile/ui/UserProfilePanelSections.tsx | 9 +++++++-- .../src/features/profile/ui/UserProfilePopover.tsx | 5 ++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index f5f2439ad8..1627fe004d 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -5,6 +5,7 @@ import { formatOwnerLabel, formatProfileLabel, profileLookupsEqual, + resolveSecondaryNip05Label, resolveUserLabel, truncatePubkey, } from "./identity.ts"; @@ -221,3 +222,15 @@ test("resolved user labels preserve the safe fallback chain", () => { truncatePubkey(USER_PUBKEY), ); }); + +test("secondary NIP-05 labels do not duplicate the primary label", () => { + assert.equal( + resolveSecondaryNip05Label("user@nip05.test", " user@nip05.test "), + null, + ); + assert.equal( + resolveSecondaryNip05Label("Profile name", " user@nip05.test "), + "user@nip05.test", + ); + assert.equal(resolveSecondaryNip05Label("Profile name", " "), null); +}); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index b419315042..27ac246f0f 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -162,6 +162,15 @@ export function resolveUserSecondaryLabel(input: { return null; } +/** Returns a trimmed NIP-05 label only when it adds distinct secondary text. */ +export function resolveSecondaryNip05Label( + primaryLabel: string, + nip05Handle: string | null | undefined, +): string | null { + const handle = nip05Handle?.trim(); + return handle && handle !== primaryLabel.trim() ? handle : null; +} + /** * Label for an agent's owner: "you" when the current user owns it, otherwise * the owner's display name, NIP-05 handle, or truncated pubkey. diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index fa60319bdb..966c6686c1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -16,6 +16,7 @@ import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; +import { resolveSecondaryNip05Label } from "@/features/profile/lib/identity"; import type { useFollowMutation, useUnfollowMutation, @@ -487,6 +488,10 @@ function ProfileHero({ userStatus: ProfileSummaryViewProps["userStatus"]; }) { const presenceDotClassName = isBot ? "h-4.5 w-4.5" : "h-3.5 w-3.5"; + const nip05Handle = resolveSecondaryNip05Label( + displayName, + profile?.nip05Handle, + ); return (
@@ -544,8 +549,8 @@ function ProfileHero({ /> ) : null} - {profile?.nip05Handle ? ( -

{profile.nip05Handle}

+ {nip05Handle ? ( +

{nip05Handle}

) : null} {userStatus ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 930e1ce66d..430e0f855c 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -28,6 +28,7 @@ import { formatProfileLabel, formatOwnerLabel, ownsAuthorAgent, + resolveSecondaryNip05Label, } from "@/features/profile/lib/identity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { usePresenceQuery } from "@/features/presence/hooks"; @@ -280,7 +281,9 @@ export function UserProfilePopover({ const userStatusText = userStatus?.text.trim() ?? ""; const hasUserStatus = Boolean(userStatusText || userStatus?.emoji); const profileDescription = profile?.about?.trim() ?? ""; - const profileSubheader = profileDescription || profile?.nip05Handle?.trim(); + const profileSubheader = + profileDescription || + resolveSecondaryNip05Label(displayName, profile?.nip05Handle); const activeTurns = useAgentWorking(isBotProfile ? pubkey : null).channels; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { From b8bebc695a110d365513b103d10208532a0c7b0f Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:32:37 -0500 Subject: [PATCH 19/40] test(desktop): register current binding browser coverage Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 231691118f..fd9fd4f93e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", + "**/current-binding-status.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", From 0d49257ae6fa816d881321586a02cdfe50001b6e Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:48:13 -0500 Subject: [PATCH 20/40] fix(desktop): connect binding projection to relay socket Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src/app/App.tsx | 2 - .../CurrentProjectionBridge.test.mjs | 172 ------------------ .../CurrentProjectionBridge.tsx | 146 --------------- desktop/src/shared/api/relayClient.ts | 3 +- desktop/src/shared/api/relayClientSession.ts | 46 ++++- desktop/src/testing/e2eBridge.ts | 26 ++- .../tests/e2e/current-binding-status.spec.ts | 38 +--- 7 files changed, 77 insertions(+), 356 deletions(-) delete mode 100644 desktop/src/features/binding-status/CurrentProjectionBridge.test.mjs delete mode 100644 desktop/src/features/binding-status/CurrentProjectionBridge.tsx diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index c57ede1315..104edcbaaf 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,7 +20,6 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; -import { CurrentProjectionBridge } from "@/features/binding-status/CurrentProjectionBridge"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; @@ -548,7 +547,6 @@ function CommunityApp({ if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( - { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -} - -async function flushPromises() { - await Promise.resolve(); - await Promise.resolve(); -} - -function makeBridge() { - const listenerReady = deferred(); - const snapshotReady = deferred(); - const applied = []; - let eventHandler; - let loadCalls = 0; - let clearCalls = 0; - let unlistenCalls = 0; - - const cleanup = mountCurrentProjectionBridge({ - listenForProjection(handler) { - eventHandler = handler; - return listenerReady.promise; - }, - loadProjection() { - loadCalls += 1; - return snapshotReady.promise; - }, - applyProjection(candidate) { - applied.push(candidate); - }, - clearProjection() { - clearCalls += 1; - }, - }); - - return { - applied, - cleanup, - emit: (candidate) => eventHandler(candidate), - listenerReady, - snapshotReady, - get loadCalls() { - return loadCalls; - }, - get clearCalls() { - return clearCalls; - }, - get unlistenCalls() { - return unlistenCalls; - }, - registerListener() { - listenerReady.resolve(() => { - unlistenCalls += 1; - }); - }, - }; -} - -test("registers the listener before requesting the native snapshot", async () => { - const bridge = makeBridge(); - assert.equal(bridge.clearCalls, 1, "mount starts fail-closed"); - assert.equal(bridge.loadCalls, 0); - - bridge.registerListener(); - await flushPromises(); - assert.equal(bridge.loadCalls, 1); - - const snapshot = { connectionEpoch: "opaque-snapshot" }; - bridge.snapshotReady.resolve(snapshot); - await flushPromises(); - assert.deepEqual(bridge.applied, [snapshot]); - bridge.cleanup(); -}); - -test("a live event fences a delayed bootstrap snapshot without ordering epochs", async () => { - const bridge = makeBridge(); - bridge.registerListener(); - await flushPromises(); - - const live = { connectionEpoch: "aaa" }; - bridge.emit(live); - bridge.snapshotReady.resolve({ connectionEpoch: "zzz" }); - await flushPromises(); - - assert.deepEqual(bridge.applied, [live]); - bridge.cleanup(); -}); - -test("snapshot failure clears only when no newer live event exists", async () => { - const noEvent = makeBridge(); - noEvent.registerListener(); - await flushPromises(); - noEvent.snapshotReady.reject(new Error("getter unavailable")); - await flushPromises(); - assert.equal(noEvent.clearCalls, 2); - noEvent.cleanup(); - - const newerEvent = makeBridge(); - newerEvent.registerListener(); - await flushPromises(); - const live = { connectionEpoch: "new-live-event" }; - newerEvent.emit(live); - newerEvent.snapshotReady.reject(new Error("stale getter failure")); - await flushPromises(); - assert.deepEqual(newerEvent.applied, [live]); - assert.equal(newerEvent.clearCalls, 1); - newerEvent.cleanup(); -}); - -test("listener setup failure remains fail-closed and skips the getter", async () => { - const bridge = makeBridge(); - bridge.listenerReady.reject(new Error("listen failed")); - await flushPromises(); - - assert.equal(bridge.loadCalls, 0); - assert.equal(bridge.clearCalls, 2); - assert.deepEqual(bridge.applied, []); - bridge.cleanup(); -}); - -test("teardown clears and rejects late listener and snapshot work", async () => { - const beforeRegistration = makeBridge(); - beforeRegistration.cleanup(); - beforeRegistration.registerListener(); - await flushPromises(); - assert.equal(beforeRegistration.clearCalls, 2); - assert.equal(beforeRegistration.unlistenCalls, 1); - assert.equal(beforeRegistration.loadCalls, 0); - - const duringSnapshot = makeBridge(); - duringSnapshot.registerListener(); - await flushPromises(); - duringSnapshot.cleanup(); - duringSnapshot.snapshotReady.resolve({ connectionEpoch: "late" }); - await flushPromises(); - assert.equal(duringSnapshot.clearCalls, 2); - assert.equal(duringSnapshot.unlistenCalls, 1); - assert.deepEqual(duringSnapshot.applied, []); -}); - -test("the bridge accepts an injected revision gate", () => { - let gateCreations = 0; - const listenerReady = deferred(); - const cleanup = mountCurrentProjectionBridge({ - listenForProjection: () => listenerReady.promise, - loadProjection: () => Promise.resolve(null), - applyProjection: () => {}, - clearProjection: () => {}, - createGate(apply, clear) { - gateCreations += 1; - return createCurrentProjectionRevisionGate(apply, clear); - }, - }); - - assert.equal(gateCreations, 1); - cleanup(); - listenerReady.resolve(() => {}); -}); diff --git a/desktop/src/features/binding-status/CurrentProjectionBridge.tsx b/desktop/src/features/binding-status/CurrentProjectionBridge.tsx deleted file mode 100644 index ec51721a4b..0000000000 --- a/desktop/src/features/binding-status/CurrentProjectionBridge.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { useEffect } from "react"; - -import { - applyCurrentProjectionFromNative, - clearCurrentProjection, -} from "./currentProjectionStore"; - -export const CURRENT_PROJECTION_EVENT = "current-binding-projection-changed"; -export const CURRENT_PROJECTION_GETTER = "get_current_binding_projection"; - -type SnapshotFence = { - resolve: (candidate: unknown) => void; - reject: () => void; -}; - -export type CurrentProjectionRevisionGate = { - applyEvent: (candidate: unknown) => void; - beginSnapshot: () => SnapshotFence; - failClosed: () => void; - close: () => void; -}; - -type CurrentProjectionBridgeDependencies = { - listenForProjection: ( - handler: (candidate: unknown) => void, - ) => Promise; - loadProjection: () => Promise; - applyProjection: (candidate: unknown) => void; - clearProjection: () => void; - createGate?: ( - applyProjection: (candidate: unknown) => void, - clearProjection: () => void, - ) => CurrentProjectionRevisionGate; -}; - -/** - * Fence browser-local async work without interpreting the opaque native epoch. - */ -export function createCurrentProjectionRevisionGate( - applyProjection: (candidate: unknown) => void, - clearProjection: () => void, -): CurrentProjectionRevisionGate { - let revision = 0; - let active = true; - - return { - applyEvent(candidate) { - if (!active) return; - revision += 1; - applyProjection(candidate); - }, - beginSnapshot() { - const capturedRevision = revision; - let settled = false; - const claimFence = () => { - if (settled) return false; - settled = true; - return active && revision === capturedRevision; - }; - return { - resolve(candidate) { - if (claimFence()) applyProjection(candidate); - }, - reject() { - if (claimFence()) clearProjection(); - }, - }; - }, - failClosed() { - if (!active) return; - revision += 1; - clearProjection(); - }, - close() { - if (!active) return; - active = false; - revision += 1; - clearProjection(); - }, - }; -} - -const defaultDependencies: CurrentProjectionBridgeDependencies = { - listenForProjection: (handler) => - listen(CURRENT_PROJECTION_EVENT, (event) => { - handler(event.payload); - }), - loadProjection: () => invoke(CURRENT_PROJECTION_GETTER), - applyProjection: applyCurrentProjectionFromNative, - clearProjection: clearCurrentProjection, -}; - -/** - * Register the live listener before reading the native snapshot. The local - * revision fence prevents a delayed bootstrap result from replacing a newer - * event; opaque connection epochs are deliberately never compared in React. - */ -export function mountCurrentProjectionBridge( - dependencies: CurrentProjectionBridgeDependencies = defaultDependencies, -): () => void { - let active = true; - let unlisten: UnlistenFn | null = null; - const gate = (dependencies.createGate ?? createCurrentProjectionRevisionGate)( - dependencies.applyProjection, - dependencies.clearProjection, - ); - - // Mounting must not expose state left by an earlier bridge lifecycle. - dependencies.clearProjection(); - - void dependencies - .listenForProjection((candidate) => gate.applyEvent(candidate)) - .then(async (registeredUnlisten) => { - if (!active) { - registeredUnlisten(); - return; - } - - unlisten = registeredUnlisten; - const snapshotFence = gate.beginSnapshot(); - try { - const candidate = await dependencies.loadProjection(); - snapshotFence.resolve(candidate); - } catch { - snapshotFence.reject(); - } - }) - .catch(() => { - if (active) gate.failClosed(); - }); - - return () => { - if (!active) return; - active = false; - gate.close(); - unlisten?.(); - unlisten = null; - }; -} - -export function CurrentProjectionBridge(): null { - useEffect(() => mountCurrentProjectionBridge(), []); - return null; -} diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts index 9cf4c13447..cc3ee55676 100644 --- a/desktop/src/shared/api/relayClient.ts +++ b/desktop/src/shared/api/relayClient.ts @@ -1,6 +1,7 @@ import { RelayClient } from "@/shared/api/relayClientSession"; +import { applyCurrentProjectionFromNative } from "@/features/binding-status/currentProjectionStore"; -export const relayClient = new RelayClient(); +export const relayClient = new RelayClient(applyCurrentProjectionFromNative); /** * Notify the relay client which channel is currently visible in the UI. diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index fd6758f791..75a69b0b03 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -69,6 +69,8 @@ import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; +export type RelayProjectionSink = (candidate: unknown) => void; + export class RelayClient { private wsId: number | null = null; private relayUrl: string | null = null; @@ -91,6 +93,7 @@ export class RelayClient { private hasConnectedOnce = false; private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; + private onProjectionChannel: Channel | null = null; private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; @@ -98,6 +101,10 @@ export class RelayClient { private terminal = false; + constructor( + private readonly projectionSink: RelayProjectionSink = () => {}, + ) {} + private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); private stallWatchdog = new RelayStallWatchdog({ intervalMs: STALL_CHECK_INTERVAL_MS, @@ -172,6 +179,8 @@ export class RelayClient { this.reconnectListeners.clear(); this.connectionStateEmitter.clear(); this.onMessageChannel = null; + this.onProjectionChannel = null; + this.publishProjection(null); this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; } @@ -535,6 +544,7 @@ export class RelayClient { ); const generation = ++this.connectionGeneration; + this.publishProjection(null); this.onMessageChannel = new Channel((message) => { void this.handleWsMessage(message, generation).catch((error) => { if (generation !== this.connectionGeneration) return; @@ -543,16 +553,24 @@ export class RelayClient { ); }); }); + this.onProjectionChannel = new Channel((candidate) => { + if (generation !== this.connectionGeneration) return; + this.publishProjection(candidate); + }); try { if (!this.relayUrl) { this.relayUrl = await getRelayWsUrl(); } - const wsId = await invoke("plugin:websocket|connect", { - url: this.relayUrl, - onMessage: this.onMessageChannel, - config: {}, - }); + const wsId = await invoke( + "plugin:websocket|connect_with_status", + { + url: this.relayUrl, + onMessage: this.onMessageChannel, + onProjection: this.onProjectionChannel, + config: {}, + }, + ); if (generation !== this.connectionGeneration) { void closeWebSocket(wsId, "stale connection attempt"); throw new Error("Relay connection attempt was superseded."); @@ -1014,6 +1032,8 @@ export class RelayClient { }, ) { this.onMessageChannel = null; + this.onProjectionChannel = null; + this.publishProjection(null); this.stallWatchdog.stop(); this.connectionGeneration++; if (this.stabilityTimer !== null) { @@ -1084,4 +1104,20 @@ export class RelayClient { this.scheduleReconnect(); } } + + private publishProjection(candidate: unknown) { + try { + this.projectionSink(candidate); + } catch { + // Presentation failure must not disturb relay transport. Try to clear + // once, without exposing the rejected candidate to logs or errors. + if (candidate !== null) { + try { + this.projectionSink(null); + } catch { + // The projection is optional presentation; relay operation remains. + } + } + } + } } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c6027fec67..72bcd0076c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -951,6 +951,7 @@ type MockFilter = { type MockSocket = { handler: WsHandler; + projectionHandler?: WsHandler; subscriptions: Map; }; @@ -1118,6 +1119,7 @@ declare global { event: string, payload: unknown, ) => Promise; + __BUZZ_E2E_EMIT_CURRENT_PROJECTION__?: (payload: unknown) => boolean; __BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: { members: MockHuddleMemberSeed[]; transcriptionEnabled: boolean; @@ -9315,7 +9317,11 @@ async function resolveGetEvent( return JSON.stringify(events[0]); } -async function connectRealSocket(args: { url?: string; onMessage: unknown }) { +async function connectRealSocket(args: { + url?: string; + onMessage: unknown; + onProjection?: unknown; +}) { relayWebsocketConnectAttemptStarts.push(Date.now()); const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); @@ -9344,7 +9350,10 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { }); } -async function connectMockSocket(args: { onMessage: unknown }) { +async function connectMockSocket(args: { + onMessage: unknown; + onProjection?: unknown; +}) { relayWebsocketConnectAttemptStarts.push(Date.now()); if (mockWebsocketUnavailable) { throw new Error("mock relay unavailable"); @@ -9363,6 +9372,10 @@ async function connectMockSocket(args: { onMessage: unknown }) { mockSockets.set(wsId, { handler, + projectionHandler: + args.onProjection === undefined + ? undefined + : resolveHandler(args.onProjection), subscriptions: new Map(), }); @@ -12477,6 +12490,7 @@ export function maybeInstallE2eTauriMocks() { ]), ); case "plugin:websocket|connect": + case "plugin:websocket|connect_with_status": if (isRelayMode(activeConfig)) { return connectRealSocket( payload as Parameters[0], @@ -12765,6 +12779,14 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) => handleMockCommand(command, payload ?? null); + window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__ = (payload) => { + const socket = [...mockSockets.values()] + .reverse() + .find((candidate) => candidate.projectionHandler !== undefined); + if (!socket?.projectionHandler) return false; + socket.projectionHandler(payload); + return true; + }; window.__BUZZ_E2E_EMIT_TAURI_EVENT__ = (event, payload) => emit(event, payload); mockIPC(handleMockCommand, { shouldMockEvents: true }); diff --git a/desktop/tests/e2e/current-binding-status.spec.ts b/desktop/tests/e2e/current-binding-status.spec.ts index 30f436c362..46a81ff4e9 100644 --- a/desktop/tests/e2e/current-binding-status.spec.ts +++ b/desktop/tests/e2e/current-binding-status.spec.ts @@ -2,11 +2,10 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; -const CURRENT_PROJECTION_EVENT = "current-binding-projection-changed"; const MATCHING_MESSAGE = "Current binding belongs to this exact author."; const OTHER_MESSAGE = "Current binding must not decorate this author."; const LEGACY_ALIAS = "legacy-relay-alias-must-stay-hidden"; -const OPAQUE_EPOCH = "opaque-epoch-must-stay-hidden"; +const CONNECTION_EPOCH = "11111111-1111-4111-8111-111111111111"; async function waitForMockLiveSubscription(page: Page) { await expect @@ -24,33 +23,18 @@ async function waitForMockLiveSubscription(page: Page) { async function waitForProjectionBridgeBootstrap(page: Page) { await expect .poll(() => - page.evaluate(() => - window.__BUZZ_E2E_COMMANDS__?.includes( - "get_current_binding_projection", - ), + page.evaluate( + () => window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__?.(null) ?? false, ), ) .toBe(true); - - // Let the unsupported mock snapshot reject and fail closed before sending a - // newer live event through the production listener/store path. - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }), - ); } async function emitProjection(page: Page, payload: unknown) { - await page.evaluate( - async ({ event, value }) => { - const emit = window.__BUZZ_E2E_EMIT_TAURI_EVENT__; - if (!emit) throw new Error("Mock Tauri event bridge is not installed."); - await emit(event, value); - }, - { event: CURRENT_PROJECTION_EVENT, value: payload }, - ); + const emitted = await page.evaluate((value) => { + return window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__?.(value) ?? false; + }, payload); + if (!emitted) throw new Error("Native projection channel is not connected."); } test("current relay binding is exact-author, generic, clearable, and passively expiring", async ({ @@ -104,11 +88,9 @@ test("current relay binding is exact-author, generic, clearable, and passively e const freshUntil = Math.floor(Date.now() / 1_000) + 30; await emitProjection(page, { - connectionEpoch: OPAQUE_EPOCH, + connectionEpoch: CONNECTION_EPOCH, eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, freshUntil, - name: LEGACY_ALIAS, - verifiedName: LEGACY_ALIAS, }); const badge = matchingRow.getByTestId("current-relay-binding"); @@ -123,7 +105,7 @@ test("current relay binding is exact-author, generic, clearable, and passively e for (const hiddenValue of [ TEST_IDENTITIES.bob.pubkey, String(freshUntil), - OPAQUE_EPOCH, + CONNECTION_EPOCH, LEGACY_ALIAS, "eventauthorpubkey", "freshuntil", @@ -151,7 +133,7 @@ test("current relay binding is exact-author, generic, clearable, and passively e const expiringFreshUntil = Math.floor(Date.now() / 1_000) + 4; await emitProjection(page, { - connectionEpoch: "opaque-expiring-epoch", + connectionEpoch: "22222222-2222-4222-8222-222222222222", eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, freshUntil: expiringFreshUntil, }); From 739a063967bdbcd2ba754a29e0b6e8bff50a4170 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:52:27 -0500 Subject: [PATCH 21/40] fix(desktop): bind projection to native relay socket Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../binding-status/currentProjectionStore.ts | 15 +- desktop/src/shared/api/relayClientSession.ts | 117 ++++++++----- .../api/relayClientStatusConnection.test.mjs | 159 ++++++++++++++++++ desktop/src/shared/api/tauri.ts | 1 + .../tests/e2e/current-binding-status.spec.ts | 1 - 5 files changed, 244 insertions(+), 49 deletions(-) create mode 100644 desktop/src/shared/api/relayClientStatusConnection.test.mjs diff --git a/desktop/src/features/binding-status/currentProjectionStore.ts b/desktop/src/features/binding-status/currentProjectionStore.ts index 7326249cfd..4c54f74d87 100644 --- a/desktop/src/features/binding-status/currentProjectionStore.ts +++ b/desktop/src/features/binding-status/currentProjectionStore.ts @@ -1,3 +1,4 @@ +import { Channel } from "@tauri-apps/api/core"; import * as React from "react"; export type CurrentProjection = Readonly<{ @@ -202,9 +203,17 @@ export function createCurrentProjectionStore( const currentProjectionStore = createCurrentProjectionStore(); -/** Native bridge sink; browser presentation code should use the hook below. */ -export function applyCurrentProjectionFromNative(candidate: unknown): void { - currentProjectionStore.replaceFromNative(candidate); +/** + * Create the sole non-null intake for the browser-owned projection store. + * The caller's fence binds delivery to one current native status connection. + */ +export function createCurrentProjectionChannel( + isCurrentConnection: () => boolean, +): Channel { + return new Channel((candidate) => { + if (!isCurrentConnection()) return; + currentProjectionStore.replaceFromNative(candidate); + }); } export function clearCurrentProjection(): void { diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 75a69b0b03..89ef15925e 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -68,8 +68,16 @@ import { import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; +import { + clearCurrentProjection, + createCurrentProjectionChannel, + type CurrentProjection, +} from "@/features/binding-status/currentProjectionStore"; -export type RelayProjectionSink = (candidate: unknown) => void; +type NativeSocketBinding = Readonly<{ + id: number; + relayUrl: string; +}>; export class RelayClient { private wsId: number | null = null; @@ -93,7 +101,7 @@ export class RelayClient { private hasConnectedOnce = false; private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; - private onProjectionChannel: Channel | null = null; + private onProjectionChannel: Channel | null = null; private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; @@ -101,10 +109,6 @@ export class RelayClient { private terminal = false; - constructor( - private readonly projectionSink: RelayProjectionSink = () => {}, - ) {} - private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); private stallWatchdog = new RelayStallWatchdog({ intervalMs: STALL_CHECK_INTERVAL_MS, @@ -180,7 +184,7 @@ export class RelayClient { this.connectionStateEmitter.clear(); this.onMessageChannel = null; this.onProjectionChannel = null; - this.publishProjection(null); + clearCurrentProjection(); this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; } @@ -544,38 +548,64 @@ export class RelayClient { ); const generation = ++this.connectionGeneration; - this.publishProjection(null); + let nativeWebsocketId: number | null = null; + let resolveNativeSocketBinding!: ( + binding: NativeSocketBinding | null, + ) => void; + const nativeSocketBinding = new Promise( + (resolve) => { + resolveNativeSocketBinding = resolve; + }, + ); + let nativeSocketBindingSettled = false; + const settleNativeSocketBinding = (binding: NativeSocketBinding | null) => { + if (nativeSocketBindingSettled) return; + nativeSocketBindingSettled = true; + resolveNativeSocketBinding(binding); + }; this.onMessageChannel = new Channel((message) => { - void this.handleWsMessage(message, generation).catch((error) => { - if (generation !== this.connectionGeneration) return; - this.resetConnection( - this.normalizeRelayError(error, "Relay connection errored."), - ); - }); - }); - this.onProjectionChannel = new Channel((candidate) => { - if (generation !== this.connectionGeneration) return; - this.publishProjection(candidate); + void this.handleWsMessage(message, generation, nativeSocketBinding).catch( + (error) => { + if (generation !== this.connectionGeneration) return; + this.resetConnection( + this.normalizeRelayError(error, "Relay connection errored."), + ); + }, + ); }); + let onProjectionChannel: Channel; + onProjectionChannel = createCurrentProjectionChannel( + () => + generation === this.connectionGeneration && + nativeWebsocketId !== null && + this.wsId === nativeWebsocketId && + this.onProjectionChannel === onProjectionChannel, + ); + this.onProjectionChannel = onProjectionChannel; + clearCurrentProjection(); try { if (!this.relayUrl) { this.relayUrl = await getRelayWsUrl(); } + const connectionRelayUrl = this.relayUrl; const wsId = await invoke( "plugin:websocket|connect_with_status", { - url: this.relayUrl, + url: connectionRelayUrl, onMessage: this.onMessageChannel, - onProjection: this.onProjectionChannel, + onProjection: onProjectionChannel, config: {}, }, ); if (generation !== this.connectionGeneration) { + settleNativeSocketBinding(null); void closeWebSocket(wsId, "stale connection attempt"); throw new Error("Relay connection attempt was superseded."); } + nativeWebsocketId = wsId; this.wsId = wsId; + settleNativeSocketBinding({ id: wsId, relayUrl: connectionRelayUrl }); await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -603,6 +633,7 @@ export class RelayClient { this.stallWatchdog.start(); this.emitReconnectIfNeeded(); } catch (error) { + settleNativeSocketBinding(null); const connectionError = this.normalizeRelayError( error, "Failed to connect to relay.", @@ -771,7 +802,11 @@ export class RelayClient { }); } - private async handleWsMessage(message: unknown, generation: number) { + private async handleWsMessage( + message: unknown, + generation: number, + nativeSocketBinding: Promise, + ) { if (generation !== this.connectionGeneration) return; this.stallWatchdog.recordInbound(); @@ -804,7 +839,9 @@ export class RelayClient { const [type, ...rest] = data; if (type === "AUTH" && typeof rest[0] === "string") { - await this.handleAuthChallenge(rest[0], generation); + const binding = await nativeSocketBinding; + if (!binding || generation !== this.connectionGeneration) return; + await this.handleAuthChallenge(rest[0], generation, binding); return; } if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) { @@ -853,17 +890,22 @@ export class RelayClient { } } - private async handleAuthChallenge(challenge: string, generation: number) { - if (!this.relayUrl) { - this.relayUrl = await getRelayWsUrl(); - } - + private async handleAuthChallenge( + challenge: string, + generation: number, + nativeSocketBinding: NativeSocketBinding, + ) { const event = await createAuthEvent({ challenge, - relayUrl: this.relayUrl, + nativeWebsocketId: nativeSocketBinding.id, + relayUrl: nativeSocketBinding.relayUrl, }); - if (generation !== this.connectionGeneration || !this.authRequest) { + if ( + generation !== this.connectionGeneration || + this.wsId !== nativeSocketBinding.id || + !this.authRequest + ) { return; } @@ -1033,7 +1075,7 @@ export class RelayClient { ) { this.onMessageChannel = null; this.onProjectionChannel = null; - this.publishProjection(null); + clearCurrentProjection(); this.stallWatchdog.stop(); this.connectionGeneration++; if (this.stabilityTimer !== null) { @@ -1105,19 +1147,4 @@ export class RelayClient { } } - private publishProjection(candidate: unknown) { - try { - this.projectionSink(candidate); - } catch { - // Presentation failure must not disturb relay transport. Try to clear - // once, without exposing the rejected candidate to logs or errors. - if (candidate !== null) { - try { - this.projectionSink(null); - } catch { - // The projection is optional presentation; relay operation remains. - } - } - } - } } diff --git a/desktop/src/shared/api/relayClientStatusConnection.test.mjs b/desktop/src/shared/api/relayClientStatusConnection.test.mjs new file mode 100644 index 0000000000..50e22ee80e --- /dev/null +++ b/desktop/src/shared/api/relayClientStatusConnection.test.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const RELAY_URL = "wss://relay.example/"; +const AUTH_EVENT_ID = "aa".repeat(32); +const AUTHOR = "bb".repeat(32); +const calls = []; +const callbacks = new Map(); +let nextCallbackId = 1; +let messageChannel; +let projectionChannel; +let socketId = 4_242; +let authDelivery = "early"; + +globalThis.isTauri = true; +globalThis.window = globalThis; +globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + calls.push({ command, args }); + switch (command) { + case "get_relay_ws_url": + return Promise.resolve(RELAY_URL); + case "plugin:websocket|connect_with_status": + messageChannel = args.onMessage; + projectionChannel = args.onProjection; + // Native channels can deliver before the invoke response reaches JS. + // A projection is not eligible until this exact connection is bound. + projectionChannel.onmessage({ + connectionEpoch: "too-early", + eventAuthorPubkey: AUTHOR, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }); + const deliverAuth = () => + messageChannel.onmessage({ + type: "Text", + data: JSON.stringify(["AUTH", `${authDelivery}-challenge`]), + }); + if (authDelivery === "early") deliverAuth(); + else window.setTimeout(deliverAuth, 0); + return Promise.resolve(socketId); + case "create_auth_event": + return Promise.resolve( + JSON.stringify({ + content: "", + created_at: 1, + id: AUTH_EVENT_ID, + kind: 22_242, + pubkey: AUTHOR, + sig: "cc".repeat(64), + tags: [], + }), + ); + case "plugin:websocket|send": { + const payload = JSON.parse(args.message.data); + if (payload[0] === "AUTH") { + queueMicrotask(() => { + messageChannel.onmessage({ + type: "Text", + data: JSON.stringify(["OK", AUTH_EVENT_ID, true, ""]), + }); + }); + } + return Promise.resolve(); + } + case "plugin:websocket|disconnect": + return Promise.resolve(); + default: + throw new Error(`Unexpected Tauri command: ${command}`); + } + }, + transformCallback(callback) { + const id = nextCallbackId++; + callbacks.set(id, callback); + return id; + }, + unregisterCallback(id) { + callbacks.delete(id); + }, +}; + +const { RelayClient } = await import("./relayClientSession.ts"); +const projectionStore = await import( + "@/features/binding-status/currentProjectionStore.ts" +); + +test("primary status connection binds early AUTH and projection to its native socket", async () => { + const client = new RelayClient(); + await client.preconnect(); + + const connectCalls = calls.filter(({ command }) => + command.startsWith("plugin:websocket|connect"), + ); + assert.equal(connectCalls.length, 1); + assert.equal(connectCalls[0].command, "plugin:websocket|connect_with_status"); + assert.equal(connectCalls[0].args.url, RELAY_URL); + assert.equal(connectCalls[0].args.onMessage, messageChannel); + assert.equal(connectCalls[0].args.onProjection, projectionChannel); + assert.equal( + projectionStore.getCurrentProjectionSnapshot(), + null, + "projection delivered before the native id is bound stays fenced", + ); + + const authCall = calls.find(({ command }) => command === "create_auth_event"); + assert.deepEqual(authCall?.args, { + challenge: "early-challenge", + nativeWebsocketId: socketId, + relayUrl: RELAY_URL, + }); + const authSend = calls.find(({ command, args }) => { + if (command !== "plugin:websocket|send") return false; + return JSON.parse(args.message.data)[0] === "AUTH"; + }); + assert.equal(authSend?.args.id, socketId); + + const current = { + connectionEpoch: "opaque-native-epoch", + eventAuthorPubkey: AUTHOR, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }; + projectionChannel.onmessage(current); + assert.deepEqual(projectionStore.getCurrentProjectionSnapshot(), current); + + client.disconnect(); + assert.equal(projectionStore.getCurrentProjectionSnapshot(), null); + projectionChannel.onmessage(current); + assert.equal( + projectionStore.getCurrentProjectionSnapshot(), + null, + "a retired connection channel cannot repopulate the store", + ); + assert.equal( + "applyCurrentProjectionFromNative" in projectionStore, + false, + "the singleton has no browser-callable direct population fixture", + ); +}); + +test("AUTH delivered after connect uses the same returned native socket id", async () => { + calls.length = 0; + authDelivery = "normal"; + socketId = 7_777; + + const client = new RelayClient(); + await client.preconnect(); + + const authCall = calls.find(({ command }) => command === "create_auth_event"); + assert.deepEqual(authCall?.args, { + challenge: "normal-challenge", + nativeWebsocketId: socketId, + relayUrl: RELAY_URL, + }); + const authSend = calls.find(({ command, args }) => { + if (command !== "plugin:websocket|send") return false; + return JSON.parse(args.message.data)[0] === "AUTH"; + }); + assert.equal(authSend?.args.id, socketId); + client.disconnect(); +}); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c44fd3b1c0..92b43fe617 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -668,6 +668,7 @@ export async function signRelayEvent(input: { export async function createAuthEvent(input: { challenge: string; + nativeWebsocketId?: number; relayUrl: string; }): Promise { const eventJson = await invokeTauri("create_auth_event", input); diff --git a/desktop/tests/e2e/current-binding-status.spec.ts b/desktop/tests/e2e/current-binding-status.spec.ts index 46a81ff4e9..e68fb0d519 100644 --- a/desktop/tests/e2e/current-binding-status.spec.ts +++ b/desktop/tests/e2e/current-binding-status.spec.ts @@ -92,7 +92,6 @@ test("current relay binding is exact-author, generic, clearable, and passively e eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, freshUntil, }); - const badge = matchingRow.getByTestId("current-relay-binding"); await expect(badge).toHaveCount(1); await expect(badge).toHaveAccessibleName("Current relay binding"); From 1732938da0af4038e7568cbfd2b110091d07743c Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:54:46 -0500 Subject: [PATCH 22/40] fix(desktop): complete native projection join Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src/shared/api/relayClient.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts index cc3ee55676..9cf4c13447 100644 --- a/desktop/src/shared/api/relayClient.ts +++ b/desktop/src/shared/api/relayClient.ts @@ -1,7 +1,6 @@ import { RelayClient } from "@/shared/api/relayClientSession"; -import { applyCurrentProjectionFromNative } from "@/features/binding-status/currentProjectionStore"; -export const relayClient = new RelayClient(applyCurrentProjectionFromNative); +export const relayClient = new RelayClient(); /** * Notify the relay client which channel is currently visible in the UI. From 8f7ae804a73cc75bf26b6ea4422b027a1dca0899 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:56:15 -0500 Subject: [PATCH 23/40] test(desktop): scope status connection mock Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src/shared/api/relayClientStatusConnection.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/api/relayClientStatusConnection.test.mjs b/desktop/src/shared/api/relayClientStatusConnection.test.mjs index 50e22ee80e..98a68a592b 100644 --- a/desktop/src/shared/api/relayClientStatusConnection.test.mjs +++ b/desktop/src/shared/api/relayClientStatusConnection.test.mjs @@ -20,7 +20,7 @@ globalThis.window.__TAURI_INTERNALS__ = { switch (command) { case "get_relay_ws_url": return Promise.resolve(RELAY_URL); - case "plugin:websocket|connect_with_status": + case "plugin:websocket|connect_with_status": { messageChannel = args.onMessage; projectionChannel = args.onProjection; // Native channels can deliver before the invoke response reaches JS. @@ -38,6 +38,7 @@ globalThis.window.__TAURI_INTERNALS__ = { if (authDelivery === "early") deliverAuth(); else window.setTimeout(deliverAuth, 0); return Promise.resolve(socketId); + } case "create_auth_event": return Promise.resolve( JSON.stringify({ From 46e045bc1fc807bdd0a9f368ab51eb8c1539c8c7 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:57:38 -0500 Subject: [PATCH 24/40] fix(desktop): reject projection DTO extensions Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../currentProjectionStore.test.mjs | 15 ++++++++++----- .../binding-status/currentProjectionStore.ts | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/binding-status/currentProjectionStore.test.mjs b/desktop/src/features/binding-status/currentProjectionStore.test.mjs index f9b083c075..9d108960ab 100644 --- a/desktop/src/features/binding-status/currentProjectionStore.test.mjs +++ b/desktop/src/features/binding-status/currentProjectionStore.test.mjs @@ -57,11 +57,8 @@ function makeTimerHost(initialNow = 100) { }; } -test("parses only the frozen narrow DTO and strips native extras", () => { - const parsed = parseCurrentProjection( - projection({ rawEvent: "must-not-cross", revision: 42 }), - 100, - ); +test("parses only the exact frozen narrow DTO", () => { + const parsed = parseCurrentProjection(projection(), 100); assert.deepEqual(parsed, projection()); assert.deepEqual(Object.keys(parsed), [ @@ -73,6 +70,14 @@ test("parses only the frozen narrow DTO and strips native extras", () => { assert.throws(() => { parsed.connectionEpoch = "mutated"; }, TypeError); + + assert.equal( + parseCurrentProjection( + projection({ rawEvent: "must-not-cross", revision: 42 }), + 100, + ), + null, + ); }); test("rejects noncanonical authors, invalid deadlines, and empty epochs", () => { diff --git a/desktop/src/features/binding-status/currentProjectionStore.ts b/desktop/src/features/binding-status/currentProjectionStore.ts index 4c54f74d87..1666780928 100644 --- a/desktop/src/features/binding-status/currentProjectionStore.ts +++ b/desktop/src/features/binding-status/currentProjectionStore.ts @@ -25,6 +25,11 @@ export type CurrentProjectionStore = { }; const LOWERCASE_HEX_PUBKEY = /^[0-9a-f]{64}$/; +const CURRENT_PROJECTION_KEYS = [ + "connectionEpoch", + "eventAuthorPubkey", + "freshUntil", +] as const; const DEFAULT_MAX_TIMER_DELAY_MS = 2_147_483_647; function logListenerError(): void { @@ -36,8 +41,8 @@ function logListenerError(): void { /** * Copy the narrow native DTO into a frozen browser-owned value. * - * Unknown properties are intentionally discarded. Expired projections are - * represented by null; the deadline is exclusive. + * Unknown properties fail closed. Expired projections are represented by + * null; the deadline is exclusive. */ export function parseCurrentProjection( candidate: unknown, @@ -53,6 +58,13 @@ export function parseCurrentProjection( } const value = candidate as Record; + const keys = Object.keys(value).sort(); + if ( + keys.length !== CURRENT_PROJECTION_KEYS.length || + !CURRENT_PROJECTION_KEYS.every((key, index) => keys[index] === key) + ) { + return null; + } const { eventAuthorPubkey, freshUntil, connectionEpoch } = value; if ( typeof eventAuthorPubkey !== "string" || From 1948d348681bcb88d5394867eb4ec8e40b6847fa Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:58:57 -0500 Subject: [PATCH 25/40] test(desktop): install projection adapter before mock IPC Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> # Conflicts: # desktop/tests/e2e/current-binding-status.spec.ts From d75c10e0de1cbccd7aa9caa1e683cb27f4661779 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:19:09 -0500 Subject: [PATCH 26/40] test(relay): add current binding loopback harness Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../j3c_current_binding_relay_harness.rs | 885 ++++++++++++++++++ 1 file changed, 885 insertions(+) create mode 100644 crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs diff --git a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs new file mode 100644 index 0000000000..4f278be73a --- /dev/null +++ b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs @@ -0,0 +1,885 @@ +//! Test-only J3C relay-authenticated client-binding status composition. +//! +//! This harness deliberately composes only public production contracts. It +//! binds a real loopback WebSocket, creates verification-only evidence through +//! the authorization finalizer, asks the production issuer and exact-connection +//! transport to deliver, frames that issuer-produced event with the production +//! relay serializer, and folds it with the production client tracker. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::atomic::{AtomicU64, AtomicU8, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::evidence_adapter::{ActiveBindingResolution, VerifiedEvidenceAdapter}; +use buzz_auth::{ + resolve_authorization, resolve_current_federated_policy, ApplicationLeaseLimit, + AssertionTransport, AuthContextInput, AuthTransport, AuthorityAdapterError, + AuthorityAdapterFuture, AuthorizationCapability, AuthorizationClock, AuthorizationClockError, + AuthorizationClockSkew, AuthorizationFinalizer, AuthorizationOutcome, AuthorizationProfileId, + AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationTime, + AuthorizedCommunityAccess, BindingExpiry, BindingLeaseBound, BindingResolutionRequest, + BindingSource, BindingVersion, CapabilitySet, CurrentPolicyRequest, + CurrentPolicyResolutionSink, DirectBindingResolutionSink, EnrollmentMode, + ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, + FederatedIdentityRequirement, PolicyVersion, ProviderAllow, ProviderAuthorizationClock, + ProviderDecision, ProviderTimeout, Scope, VerificationOnlyDisposition, + VerificationStatusPolicy, +}; +use buzz_core::client_binding_status::{ + ClientBindingStatusError, ClientBindingStatusFoldError, ClientBindingStatusInputV1, + ClientBindingStatusTracker, ClientBindingStatusUpdate, +}; +use buzz_core::kind::{KIND_CLIENT_BINDING_STATUS, KIND_USER_TRUSTED_ASSERTION}; +use buzz_core::CommunityId; +use buzz_relay::authorization_runtime::status::{ + AuthoritativeClientStatusEvidence, ClientStatusPresentationGateError, + ClientStatusPresentationPermit, ClientStatusPrivacyKey, ClientStatusRevisionScope, + CompleteClientStatusPresentationApproval, ConnectionManagerClientStatusTransport, + DurableClientStatusRevision, DurableClientStatusRevisionSource, ProviderNeutralPolicyRevision, + RelayClientBindingStatusIssuer, +}; +use buzz_relay::connection::OutboundData; +use buzz_relay::protocol::RelayMessage; +use buzz_relay::state::ConnectionManager; +use futures::{SinkExt, StreamExt}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, RelayUrl, Timestamp}; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, Mutex as AsyncMutex}; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const STATUS_SUBSCRIPTION: &str = "__buzz_client_binding_status_v1__"; + +#[derive(Clone)] +struct FixedClock(u64); + +impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(self.0)) + } +} + +impl ProviderAuthorizationClock for FixedClock { + fn now_unix_seconds(&self) -> Option { + Some(self.0) + } +} + +struct SyntheticAuthority { + policy_id: Uuid, + binding_id: Uuid, + binding_version: BindingVersion, + valid_until: u64, +} + +impl SyntheticAuthority { + fn resolve_binding( + &self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> Result> + { + Ok(sink.existing_active( + request.authorization_domain(), + self.binding_id, + request.principal().clone(), + request.bound_pubkey(), + self.binding_version, + Some(BindingExpiry::new(self.valid_until)?), + BindingSource::AttestedKey, + )?) + } +} + +impl FederatedAuthorityAdapter for SyntheticAuthority { + type Error = Infallible; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + Ok(sink.resolved( + request.authorization_domain(), + self.policy_id, + 1, + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + request.observed_at().saturating_sub(1), + self.valid_until, + )?) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result< + buzz_auth::context::AuthoritativeBindingResolution, + AuthorityAdapterError, + >, + > { + Box::pin(async move { self.resolve_binding(request, sink) }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result< + buzz_auth::context::AuthoritativeBindingResolution, + AuthorityAdapterError, + >, + > { + Box::pin(async move { + Ok(sink.existing_active( + request.authorization_domain(), + self.binding_id, + request.principal().clone(), + request.bound_pubkey(), + self.binding_version, + Some(BindingExpiry::new(self.valid_until)?), + BindingSource::AttestedKey, + )?) + }) + } +} + +struct SyntheticProvider { + profile: AuthorizationProfileId, + policy: PolicyVersion, + issued_at: u64, + fresh_until: u64, +} + +impl AuthorizationProvider for SyntheticProvider { + fn profile_id(&self) -> AuthorizationProfileId { + self.profile.clone() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + let decision = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + self.profile.clone(), + request.requested_capabilities().clone(), + self.policy.clone(), + self.issued_at, + self.fresh_until, + ) + .expect("synthetic provider output must satisfy the production contract"); + Box::pin(std::future::ready(ProviderDecision::Allow(decision))) + } +} + +struct CompleteSyntheticApproval { + reviewed_revision: String, +} + +impl CompleteClientStatusPresentationApproval for CompleteSyntheticApproval { + fn reviewed_implementation_revision(&self) -> &str { + &self.reviewed_revision + } + + fn presentation_gate_passed(&self) -> bool { + true + } + + fn dedicated_client_contract_passed(&self) -> bool { + true + } +} + +struct SyntheticRevisions { + revision: AtomicU64, + current_reads: AtomicUsize, + withdrawal_reads: AtomicUsize, + scopes: Mutex>, +} + +impl SyntheticRevisions { + fn new(revision: u64) -> Self { + Self { + revision: AtomicU64::new(revision), + current_reads: AtomicUsize::new(0), + withdrawal_reads: AtomicUsize::new(0), + scopes: Mutex::new(Vec::new()), + } + } + + fn set(&self, revision: u64) { + self.revision.store(revision, Ordering::SeqCst); + } + + fn durable(&self) -> Option { + let revision = self.revision.load(Ordering::SeqCst); + DurableClientStatusRevision::from_durable_state(revision, revision).ok() + } +} + +#[async_trait] +impl DurableClientStatusRevisionSource for SyntheticRevisions { + async fn current_revision_for( + &self, + requirement: &buzz_relay::authorization_runtime::status::ClientStatusCurrentRequirement<'_>, + _issuance_fingerprint: [u8; 32], + ) -> Option { + self.current_reads.fetch_add(1, Ordering::SeqCst); + self.scopes + .lock() + .expect("synthetic revision scope lock") + .push(requirement.scope()); + self.durable() + } + + async fn withdrawal_revision_for( + &self, + receipt: &buzz_relay::authorization_runtime::status::ClientStatusIssuanceReceipt, + _withdrawal_fingerprint: [u8; 32], + ) -> Option { + self.withdrawal_reads.fetch_add(1, Ordering::SeqCst); + assert!(!receipt.connection_id().is_nil()); + self.durable() + } +} + +async fn verification_only_disposition( + relay_url: &str, + domain: CommunityId, + author: &Keys, + now: u64, +) -> (VerificationOnlyDisposition, ClientStatusPrivacyKey) { + let adapter = VerifiedEvidenceAdapter::new(); + let challenge = Uuid::new_v4().to_string(); + let auth_event = EventBuilder::auth( + challenge.clone(), + RelayUrl::parse(relay_url).expect("loopback relay URL is valid"), + ) + .sign_with_keys(author) + .expect("ephemeral author signs NIP-42 proof"); + let proof = adapter + .verify_nip42( + domain, + AuthTransport::RelayWebSocket, + &auth_event, + &challenge, + relay_url, + None, + ) + .expect("production verifier seals the loopback NIP-42 proof"); + + let issuer = format!("https://{}.invalid", Uuid::new_v4()); + let subject = Uuid::new_v4().to_string(); + let assertion = adapter + .federated_assertion_from_validated_claims( + domain, + AuthTransport::RelayWebSocket, + &issuer, + &subject, + Some(author.public_key()), + AssertionTransport::TrustedProxy, + Some(now.saturating_sub(1)), + now + 240, + now, + ) + .expect("synthetic validated claims seal exact assertion evidence"); + let correlation_id = Uuid::new_v4(); + let authority = SyntheticAuthority { + policy_id: Uuid::new_v4(), + binding_id: Uuid::new_v4(), + binding_version: BindingVersion::new(7).expect("positive synthetic binding version"), + valid_until: now + 240, + }; + let policy = resolve_current_federated_policy(&authority, domain, correlation_id, now) + .await + .expect("test-only authoritative policy resolves"); + let profile = AuthorizationProfileId::from_server_configuration(format!( + "synthetic-profile-{}", + Uuid::new_v4() + )) + .expect("synthetic profile is valid"); + let provider_policy = PolicyVersion::new(format!("private-policy-{}", Uuid::new_v4())) + .expect("synthetic provider policy is valid"); + let capabilities = CapabilitySet::single(AuthorizationCapability::CommunityRead); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities, + correlation_id, + now, + ) + .expect("exact direct provider request is valid"); + let provider = SyntheticProvider { + profile: profile.clone(), + policy: provider_policy, + issued_at: now, + fresh_until: now + 180, + }; + let snapshot = match resolve_authorization( + &provider, + &request, + &FixedClock(now), + ProviderTimeout::new(Duration::from_secs(1)).expect("bounded provider timeout"), + Uuid::new_v4(), + ) + .await + { + AuthorizationOutcome::Allow(snapshot) => snapshot, + other => panic!("synthetic exact provider request must allow: {other:?}"), + }; + let binding = adapter + .active_binding_from_store( + domain, + domain, + authority.binding_id, + &issuer, + &subject, + author.public_key(), + authority.binding_version.get(), + Some(authority.valid_until), + BindingSource::AttestedKey, + ActiveBindingResolution::Existing, + Some(&assertion), + ) + .expect("typed current binding store output seals"); + let binding_bound = BindingLeaseBound::new(&binding, authority.valid_until) + .expect("synthetic binding bound is current"); + let tenant = + buzz_core::tenant::TenantContext::resolved(domain, format!("{}.invalid", Uuid::new_v4())); + let admission: AuthorizedCommunityAccess = adapter + .community_access_from_policy(&tenant, domain, vec![Scope::MessagesRead], None) + .expect("server-resolved community admission seals"); + let input = AuthContextInput::new(tenant, correlation_id, proof, admission); + let policy = resolve_current_federated_policy(&authority, domain, correlation_id, now) + .await + .expect("same current policy resolves at finalization"); + let finalizer = AuthorizationFinalizer::new(Arc::new(FixedClock(now))); + let disposition = finalizer + .finalize_verification_only( + input, + policy, + FederatedAuthorization::Direct { binding, assertion }, + snapshot, + &profile, + binding_bound, + VerificationStatusPolicy::new( + ApplicationLeaseLimit::from_seconds(120).expect("short display lifetime is valid"), + AuthorizationClockSkew::from_seconds(0).expect("zero skew is valid"), + ), + ) + .expect("production finalizer yields display-only evidence"); + let privacy_key = ClientStatusPrivacyKey::from_secret(rand::random()); + (disposition, privacy_key) +} + +fn register_authenticated_connection( + connections: &ConnectionManager, + connection_id: Uuid, + domain: CommunityId, + author: &Keys, +) -> ( + mpsc::Receiver, + mpsc::Receiver, +) { + let (tx, rx) = mpsc::channel(16); + let (ctrl_tx, ctrl_rx) = mpsc::channel(4); + connections.register( + connection_id, + tx, + ctrl_tx, + CancellationToken::new(), + domain, + Arc::new(AtomicU8::new(0)), + Arc::new(AsyncMutex::new(HashMap::new())), + 3, + ); + connections.set_authenticated_pubkey(connection_id, author.public_key().to_bytes().to_vec()); + (rx, ctrl_rx) +} + +fn authoritative_evidence( + disposition: &VerificationOnlyDisposition, + privacy_key: &ClientStatusPrivacyKey, +) -> AuthoritativeClientStatusEvidence { + let policy_revision = ProviderNeutralPolicyRevision::derive( + privacy_key, + disposition.profile_id(), + disposition.policy_version(), + ) + .expect("ephemeral privacy key derives provider-neutral policy revision"); + AuthoritativeClientStatusEvidence::from_authoritative_runtime( + disposition.authorization_domain(), + disposition.actor_pubkey(), + disposition.binding_id(), + disposition.binding_version(), + disposition.profile_id().clone(), + disposition.policy_version().clone(), + policy_revision, + disposition.correlation_id(), + 1, + disposition.issued_at(), + disposition.expires_at(), + ) +} + +fn raw_signed_event(keys: &Keys, kind: u32, content: String, issued_at: u64) -> Event { + EventBuilder::new(Kind::Custom(kind as u16), content) + .custom_created_at(Timestamp::from(issued_at)) + .sign_with_keys(keys) + .expect("ephemeral synthetic event signs") +} + +async fn receive_status( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> Event { + let message = timeout(Duration::from_secs(2), socket.next()) + .await + .expect("loopback status frame must not time out") + .expect("loopback relay remains connected") + .expect("loopback WebSocket frame is valid"); + let Message::Text(text) = message else { + panic!("client status must use a text WebSocket frame"); + }; + let envelope: Value = serde_json::from_str(&text).expect("relay frame is JSON"); + assert_eq!(envelope[0], "EVENT"); + assert_eq!(envelope[1], STATUS_SUBSCRIPTION); + Event::from_json(envelope[2].to_string()).expect("relay frame carries a Nostr event") +} + +async fn round_trip( + sender: &mpsc::UnboundedSender, + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + event: &Event, +) -> Event { + sender + .send(RelayMessage::event(STATUS_SUBSCRIPTION, event)) + .expect("loopback relay task remains live"); + let received = receive_status(socket).await; + assert_eq!(received.id, event.id, "wire event must be issuer-produced"); + received +} + +#[tokio::test] +async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scope() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("ephemeral loopback listener binds"); + let address = listener + .local_addr() + .expect("loopback listener has an address"); + assert_ne!(address.port(), 0, "OS must allocate a real ephemeral port"); + let relay_url = format!("ws://{address}"); + let (frame_tx, mut frame_rx) = mpsc::unbounded_channel::(); + let server = tokio::spawn(async move { + let (tcp, peer) = listener.accept().await.expect("loopback client connects"); + assert!(peer.ip().is_loopback(), "harness must remain loopback-only"); + let mut websocket = tokio_tungstenite::accept_async(tcp) + .await + .expect("loopback WebSocket upgrades"); + let mut sent = 0usize; + while let Some(frame) = frame_rx.recv().await { + websocket + .send(Message::Text(frame.into())) + .await + .expect("loopback status frame sends"); + sent += 1; + } + let _ = websocket.close(None).await; + sent + }); + let (mut socket, _) = tokio_tungstenite::connect_async(&relay_url) + .await + .expect("real loopback WebSocket client connects"); + + let now = Timestamp::now().as_secs(); + let relay = Keys::generate(); + let author = Keys::generate(); + let spoof = Keys::generate(); + let wrong_relay = Keys::generate(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let wrong_domain = CommunityId::from_uuid(Uuid::new_v4()); + let connection_id = Uuid::new_v4(); + let (disposition, privacy_key) = + verification_only_disposition(&relay_url, domain, &author, now).await; + let evidence = authoritative_evidence(&disposition, &privacy_key); + let revisions = SyntheticRevisions::new(10); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &revisions, &privacy_key); + let permit = ClientStatusPresentationPermit::from_complete_stack(&CompleteSyntheticApproval { + reviewed_revision: "a".repeat(40), + }) + .expect("test-only complete approval constructs the production gate"); + + let connections = Arc::new(ConnectionManager::new()); + let (_outbound_rx, _ctrl_rx) = + register_authenticated_connection(&connections, connection_id, domain, &author); + let transport = ConnectionManagerClientStatusTransport::new(Arc::clone(&connections)); + let current_attempt = issuer + .deliver_verification_only(&permit, &disposition, 1, None, connection_id, &transport) + .await + .expect("production issuer creates current status"); + assert_eq!(current_attempt.delivery_error(), None); + assert_eq!(current_attempt.receipt().connection_id(), connection_id); + assert_eq!(current_attempt.receipt().revision(), 10); + assert_eq!(current_attempt.event().pubkey, relay.public_key()); + assert!(current_attempt.event().verify().is_ok()); + assert!(!current_attempt + .event() + .content + .contains(disposition.profile_id().as_str())); + assert!(!current_attempt + .event() + .content + .contains(disposition.policy_version().as_str())); + + // The same issuer cannot target a connection authenticated as another key + // or resolved for another authorization domain. + let wrong_author_connection = Uuid::new_v4(); + let (_wrong_author_rx, _wrong_author_ctrl_rx) = + register_authenticated_connection(&connections, wrong_author_connection, domain, &spoof); + let wrong_author_attempt = issuer + .deliver_verification_only( + &permit, + &disposition, + 1, + None, + wrong_author_connection, + &transport, + ) + .await + .expect("issuance succeeds independently of exact delivery"); + assert!(wrong_author_attempt.delivery_error().is_some()); + + let wrong_domain_connection = Uuid::new_v4(); + let (_wrong_domain_rx, _wrong_domain_ctrl_rx) = register_authenticated_connection( + &connections, + wrong_domain_connection, + wrong_domain, + &author, + ); + let wrong_domain_attempt = issuer + .deliver_verification_only( + &permit, + &disposition, + 1, + None, + wrong_domain_connection, + &transport, + ) + .await + .expect("issuance succeeds independently of exact delivery"); + assert!(wrong_domain_attempt.delivery_error().is_some()); + + revisions.set(10); + let current = round_trip(&frame_tx, &mut socket, current_attempt.event()).await; + let mut tracker = + ClientBindingStatusTracker::new(relay.public_key(), domain, author.public_key()); + assert_eq!( + tracker.accept(¤t, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_some()); + assert_eq!(tracker.high_water_revision(), Some(10)); + + let malformed = raw_signed_event(&relay, KIND_CLIENT_BINDING_STATUS, "{".to_string(), now); + let malformed = round_trip(&frame_tx, &mut socket, &malformed).await; + assert_eq!( + tracker.accept(&malformed, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::MalformedPayload + )) + ); + + let mut unsupported_content: Value = + serde_json::from_str(¤t.content).expect("current status content is JSON"); + unsupported_content["version"] = json!(2); + let unsupported = raw_signed_event( + &relay, + KIND_CLIENT_BINDING_STATUS, + unsupported_content.to_string(), + now, + ); + let unsupported = round_trip(&frame_tx, &mut socket, &unsupported).await; + assert_eq!( + tracker.accept(&unsupported, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::UnsupportedVersion + )) + ); + + let wrong_signer = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 7, + "opaque-wrong-relay", + 11, + now, + now + 120, + None, + ) + .expect("bounded wrong-relay status input") + .sign_with_relay_keys(&wrong_relay) + .expect("wrong relay still produces an authenticated Nostr event"); + let wrong_signer = round_trip(&frame_tx, &mut socket, &wrong_signer).await; + assert_eq!( + tracker.accept(&wrong_signer, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::UnexpectedRelay + )) + ); + + let author_mismatch = ClientBindingStatusInputV1::current( + domain, + spoof.public_key(), + 7, + "opaque-author-spoof", + 11, + now, + now + 120, + None, + ) + .expect("bounded mismatched-author status input") + .sign_with_relay_keys(&relay) + .expect("relay signs explicit mismatched-author test event"); + let author_mismatch = round_trip(&frame_tx, &mut socket, &author_mismatch).await; + assert_eq!( + tracker.accept(&author_mismatch, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::EventAuthorMismatch + )) + ); + + let domain_mismatch = ClientBindingStatusInputV1::current( + wrong_domain, + author.public_key(), + 7, + "opaque-domain-spoof", + 11, + now, + now + 120, + None, + ) + .expect("bounded mismatched-domain status input") + .sign_with_relay_keys(&relay) + .expect("relay signs explicit mismatched-domain test event"); + let domain_mismatch = round_trip(&frame_tx, &mut socket, &domain_mismatch).await; + assert_eq!( + tracker.accept(&domain_mismatch, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::AuthorizationDomainMismatch + )) + ); + + // Neither mutable profile metadata nor a legacy NIP-85 assertion can + // restore or rename the relay-authenticated status presentation. + for legacy in [ + raw_signed_event( + &spoof, + Kind::Metadata.as_u16() as u32, + json!({"name": format!("spoof-{}", Uuid::new_v4())}).to_string(), + now, + ), + raw_signed_event( + &relay, + KIND_USER_TRUSTED_ASSERTION, + json!({"active": true, "label": format!("legacy-{}", Uuid::new_v4())}).to_string(), + now, + ), + ] { + let legacy = round_trip(&frame_tx, &mut socket, &legacy).await; + assert_eq!( + tracker.accept(&legacy, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::WrongKind + )) + ); + assert_eq!(tracker.high_water_revision(), Some(10)); + assert_eq!( + tracker + .current_presentation(now) + .and_then(|status| status.display_label()), + None + ); + } + + revisions.set(11); + let withdrawal = issuer + .deliver_withdrawn_after_invalidation( + &permit, + &evidence, + current_attempt.receipt(), + &transport, + ) + .await + .expect("production issuer delivers a strictly newer withdrawal"); + let withdrawal = round_trip(&frame_tx, &mut socket, &withdrawal).await; + assert_eq!( + tracker.accept(&withdrawal, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_none()); + assert_eq!( + tracker.accept(&withdrawal, now), + Ok(ClientBindingStatusUpdate::Duplicate) + ); + assert_eq!( + tracker.accept(¤t, now), + Err(ClientBindingStatusFoldError::LowerRevisionReplay) + ); + + let equal_conflict = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 8, + "opaque-equal-conflict", + 11, + now, + now + 120, + None, + ) + .expect("equal-revision conflict input is structurally valid") + .sign_with_relay_keys(&relay) + .expect("relay signs explicit conflict event"); + let equal_conflict = round_trip(&frame_tx, &mut socket, &equal_conflict).await; + assert_eq!( + tracker.accept(&equal_conflict, now), + Err(ClientBindingStatusFoldError::ConflictingEqualRevision) + ); + + revisions.set(12); + let restored_attempt = issuer + .deliver_verification_only(&permit, &disposition, 1, None, connection_id, &transport) + .await + .expect("production issuer creates strictly newer restoration"); + assert_eq!(restored_attempt.delivery_error(), None); + let restored = round_trip(&frame_tx, &mut socket, restored_attempt.event()).await; + assert_eq!( + tracker.accept(&restored, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_some()); + + tracker.on_disconnect(); + assert!(tracker.current_presentation(now).is_none()); + assert_eq!(tracker.high_water_revision(), Some(12)); + assert_eq!( + tracker.accept(&restored, now), + Ok(ClientBindingStatusUpdate::Duplicate), + "reconnect must not restore presentation from a duplicate" + ); + assert!(tracker.current_presentation(now).is_none()); + + revisions.set(13); + let reconnect_attempt = issuer + .deliver_verification_only(&permit, &disposition, 2, None, connection_id, &transport) + .await + .expect("reconnect obtains a newer production issuance"); + let reconnect = round_trip(&frame_tx, &mut socket, reconnect_attempt.event()).await; + assert_eq!( + tracker.accept(&reconnect, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_some()); + assert!(tracker + .current_presentation(disposition.expires_at()) + .is_none()); + assert_eq!(tracker.high_water_revision(), Some(13)); + + tracker.change_scope(relay.public_key(), wrong_domain, author.public_key()); + assert_eq!(tracker.high_water_revision(), None); + assert!(tracker.current_presentation(now).is_none()); + assert_eq!( + tracker.accept(&reconnect, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::AuthorizationDomainMismatch + )) + ); + + // Logout/restart starts with no projection. It does not synthesize a + // profile-derived or NIP-85-derived fallback while awaiting a new status. + let mut restarted = + ClientBindingStatusTracker::new(relay.public_key(), domain, author.public_key()); + assert!(restarted.current_presentation(now).is_none()); + assert_eq!(restarted.high_water_revision(), None); + + assert_eq!(revisions.current_reads.load(Ordering::SeqCst), 5); + assert_eq!(revisions.withdrawal_reads.load(Ordering::SeqCst), 1); + let observed_scopes = revisions + .scopes + .lock() + .expect("synthetic revision scope lock"); + assert!( + !observed_scopes.is_empty(), + "issuer must read durable scope" + ); + assert!(observed_scopes.iter().all(|scope| { + scope.authorization_domain() == domain && scope.event_author_pubkey() == author.public_key() + })); + drop(observed_scopes); + + drop(frame_tx); + let sent = server.await.expect("loopback relay task exits cleanly"); + assert!( + sent >= 12, + "non-vacuity: all status cases crossed WebSocket" + ); +} + +#[test] +fn composition_is_test_only_and_stock_production_remains_unwired() { + const HARNESS: &str = include_str!("j3c_current_binding_relay_harness.rs"); + const MAIN: &str = include_str!("../src/main.rs"); + const LIB: &str = include_str!("../src/lib.rs"); + const ROUTER: &str = include_str!("../src/router.rs"); + const STATE: &str = include_str!("../src/state.rs"); + + assert!(file!().contains("/tests/") || file!().starts_with("tests/")); + for required in [ + "TcpListener::bind(\"127.0.0.1:0\")", + "RelayClientBindingStatusIssuer::new", + "ConnectionManagerClientStatusTransport::new", + "ClientBindingStatusTracker::new", + "RelayMessage::event", + ] { + assert!( + HARNESS.contains(required), + "missing non-vacuity seam: {required}" + ); + } + for production_root in [MAIN, LIB, ROUTER, STATE] { + assert!(!production_root.contains("CompleteSyntheticApproval")); + assert!(!production_root.contains("SyntheticRevisions")); + assert!(!production_root.contains("j3c_current_binding_relay_harness")); + assert!(!production_root.contains("ProductionClientStatusRuntime::new")); + assert!(!production_root.contains("ClientStatusPresentationPermit::from_complete_stack")); + } + + let incomplete = CompleteSyntheticApproval { + reviewed_revision: "not-a-revision".to_string(), + }; + assert!(matches!( + ClientStatusPresentationPermit::from_complete_stack(&incomplete), + Err(ClientStatusPresentationGateError::Incomplete) + )); +} From b6aa38190166e9e7d8ce83455d3732b530241d3d Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:15:36 -0500 Subject: [PATCH 27/40] test(relay): prove exact client status wire bytes Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/src/connection.rs | 4 + .../connection/j3c_current_binding_wire.rs | 268 ++++++++ crates/buzz-relay/src/lib.rs | 3 + .../j3c_current_binding_relay_harness.rs | 576 ++++++++++++++---- 4 files changed, 722 insertions(+), 129 deletions(-) create mode 100644 crates/buzz-relay/src/connection/j3c_current_binding_wire.rs diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index f9e3081605..2dca8365d2 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -987,6 +987,10 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { } } +#[cfg(test)] +#[path = "connection/j3c_current_binding_wire.rs"] +mod j3c_current_binding_wire; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs new file mode 100644 index 0000000000..765a5bfc90 --- /dev/null +++ b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs @@ -0,0 +1,268 @@ +//! Exact-byte loopback proof for the test-only J3C client-status composition. + +#[path = "../../../../desktop/src-tauri/src/client_binding_status_session.rs"] +mod client_binding_status_session; + +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::atomic::AtomicU8; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::extract::ws::Message as AxumMessage; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingEpoch, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + CLIENT_BINDING_STATUS_SUB_ID, +}; +use buzz_core::client_binding_status::ClientBindingStatusInputV1; +use buzz_core::CommunityId; +use futures_util::{Sink, StreamExt}; +use nostr::{Keys, Timestamp}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, Mutex}; +use tokio::time::{timeout, Duration}; +use tokio_tungstenite::tungstenite::{ + protocol::{frame::coding::CloseCode, CloseFrame}, + Error as TungsteniteError, Message as TungsteniteMessage, +}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{send_loop_inner, OutboundData}; +use crate::protocol::RelayMessage; +use crate::state::ConnectionManager; +use client_binding_status_session::{ + ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, +}; + +struct TungsteniteSink(S); + +impl Sink for TungsteniteSink +where + S: Sink + Unpin, +{ + type Error = TungsteniteError; + + fn poll_ready( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_ready(context) + } + + fn start_send(mut self: Pin<&mut Self>, item: AxumMessage) -> Result<(), Self::Error> { + let item = match item { + AxumMessage::Text(text) => TungsteniteMessage::Text(text.to_string().into()), + AxumMessage::Binary(bytes) => TungsteniteMessage::Binary(bytes), + AxumMessage::Ping(bytes) => TungsteniteMessage::Ping(bytes), + AxumMessage::Pong(bytes) => TungsteniteMessage::Pong(bytes), + AxumMessage::Close(frame) => TungsteniteMessage::Close(frame.map(|frame| CloseFrame { + code: CloseCode::from(frame.code), + reason: frame.reason.to_string().into(), + })), + }; + Pin::new(&mut self.0).start_send(item) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_flush(context) + } + + fn poll_close( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_close(context) + } +} + +async fn receive_exact( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + expected: &str, +) -> String { + let message = timeout(Duration::from_secs(2), socket.next()) + .await + .expect("production socket writer must not time out") + .expect("loopback socket remains connected") + .expect("production socket writer emits a valid frame"); + let TungsteniteMessage::Text(text) = message else { + panic!("client-status transport must emit text"); + }; + assert_eq!(text.as_str().as_bytes(), expected.as_bytes()); + text.to_string() +} + +fn assert_current( + update: Option, + author: &Keys, + epoch: &ClientBindingEpoch, + fresh_until: u64, +) { + let Some(ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey, + fresh_until: projected_fresh_until, + connection_epoch, + })) = update + else { + panic!("exact production bytes must project current status"); + }; + assert_eq!(event_author_pubkey, author.public_key().to_hex()); + assert_eq!(projected_fresh_until, fresh_until); + assert_eq!(connection_epoch, epoch.as_str()); +} + +#[tokio::test] +async fn production_outbound_bytes_cross_loopback_into_native_status_session() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("ephemeral loopback listener binds"); + let address = listener.local_addr().expect("loopback address resolves"); + assert_ne!(address.port(), 0); + + let relay = Keys::generate(); + let author = Keys::generate(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let epoch = ClientBindingEpoch::from_random_bytes(rand::random()); + let connection_id = Uuid::new_v4(); + let now = Timestamp::now().as_secs(); + let fresh_until = now + 120; + + let connections = Arc::new(ConnectionManager::new()); + let (data_tx, data_rx) = mpsc::channel::(8); + let (ctrl_tx, ctrl_rx) = mpsc::channel(2); + let cancel = CancellationToken::new(); + connections.register( + connection_id, + data_tx, + ctrl_tx, + cancel.clone(), + domain, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + connections.set_authenticated_pubkey(connection_id, author.public_key().to_bytes().to_vec()); + + let writer_cancel = cancel.clone(); + let server = tokio::spawn(async move { + let (tcp, peer) = listener.accept().await.expect("loopback client connects"); + assert!(peer.ip().is_loopback()); + let socket = tokio_tungstenite::accept_async(tcp) + .await + .expect("loopback WebSocket upgrades"); + let (sink, _stream) = socket.split(); + send_loop_inner(TungsteniteSink(sink), data_rx, ctrl_rx, writer_cancel).await; + }); + + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://{address}")) + .await + .expect("loopback WebSocket client connects"); + let mut session = + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + + let bootstrap = + ClientBindingBootstrapInputV1::new(domain, author.public_key(), epoch.clone(), now) + .expect("connection bootstrap input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs bootstrap"); + let bootstrap_frame = RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap); + assert!(connections.send_to(connection_id, bootstrap_frame.clone())); + let bootstrap_text = receive_exact(&mut socket, &bootstrap_frame).await; + assert!(matches!( + session.consume_text(&bootstrap_text, now), + Some(ProjectionUpdate::Unchanged) + )); + + let current = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 7, + "opaque-current", + 10, + now, + fresh_until, + None, + ) + .expect("current status input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs current status"); + let current_frame = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, ¤t); + assert!(connections.send_to(connection_id, current_frame.clone())); + let current_text = receive_exact(&mut socket, ¤t_frame).await; + assert_current( + session.consume_text(¤t_text, now), + &author, + &epoch, + fresh_until, + ); + + let trusted_invalid = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 8, + "opaque-trusted-invalid", + 11, + now, + fresh_until, + None, + ) + .expect("trusted-invalid status input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs trusted-invalid status"); + let malformed_outer = serde_json::json!([ + "EVENT", + CLIENT_BINDING_STATUS_SUB_ID, + trusted_invalid, + "unexpected" + ]) + .to_string(); + assert!(connections.send_to(connection_id, malformed_outer.clone())); + let malformed_text = receive_exact(&mut socket, &malformed_outer).await; + assert!(matches!( + session.consume_text(&malformed_text, now), + Some(ProjectionUpdate::Clear) + )); + + let replay_frame = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, &trusted_invalid); + assert!(connections.send_to(connection_id, replay_frame.clone())); + let replay_text = receive_exact(&mut socket, &replay_frame).await; + assert!(matches!( + session.consume_text(&replay_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!(session.projected_fresh_until(), None); + + let newer = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 9, + "opaque-newer-restoration", + 12, + now, + fresh_until, + None, + ) + .expect("newer status input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs newer status"); + let newer_frame = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, &newer); + assert!(connections.send_to(connection_id, newer_frame.clone())); + let newer_text = receive_exact(&mut socket, &newer_frame).await; + assert_current( + session.consume_text(&newer_text, now), + &author, + &epoch, + fresh_until, + ); + + cancel.cancel(); + timeout(Duration::from_secs(2), server) + .await + .expect("production socket writer stops after cancellation") + .expect("production socket writer task does not panic"); +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314c8ac9b0..fc8f230794 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -2,6 +2,9 @@ #![warn(missing_docs)] //! NIP-01 WebSocket relay for Buzz private team communication. +#[cfg(test)] +extern crate buzz_core as buzz_core_pkg; + mod admission; /// Provider-neutral runtime authorization and bounded finalization. diff --git a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs index 4f278be73a..0a6fb65149 100644 --- a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs +++ b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs @@ -1,10 +1,16 @@ //! Test-only J3C relay-authenticated client-binding status composition. //! //! This harness deliberately composes only public production contracts. It -//! binds a real loopback WebSocket, creates verification-only evidence through -//! the authorization finalizer, asks the production issuer and exact-connection -//! transport to deliver, frames that issuer-produced event with the production -//! relay serializer, and folds it with the production client tracker. +//! binds a real loopback WebSocket, carries a real NIP-42 `AUTH` frame through +//! the Buzz parser and verifier, creates verification-only evidence through the +//! authorization finalizer, and asks the production issuer and exact-connection +//! transport to deliver. The production J1 native session source consumes the +//! resulting bootstrap/status frames. + +extern crate buzz_core as buzz_core_pkg; + +#[path = "../../../desktop/src-tauri/src/client_binding_status_session.rs"] +mod client_binding_status_session; use std::collections::HashMap; use std::convert::Infallible; @@ -13,6 +19,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; +use buzz_auth::context::BindingExpiry; use buzz_auth::evidence_adapter::{ActiveBindingResolution, VerifiedEvidenceAdapter}; use buzz_auth::{ resolve_authorization, resolve_current_federated_policy, ApplicationLeaseLimit, @@ -20,13 +27,16 @@ use buzz_auth::{ AuthorityAdapterFuture, AuthorizationCapability, AuthorizationClock, AuthorizationClockError, AuthorizationClockSkew, AuthorizationFinalizer, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationTime, - AuthorizedCommunityAccess, BindingExpiry, BindingLeaseBound, BindingResolutionRequest, - BindingSource, BindingVersion, CapabilitySet, CurrentPolicyRequest, - CurrentPolicyResolutionSink, DirectBindingResolutionSink, EnrollmentMode, - ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, - FederatedIdentityRequirement, PolicyVersion, ProviderAllow, ProviderAuthorizationClock, - ProviderDecision, ProviderTimeout, Scope, VerificationOnlyDisposition, - VerificationStatusPolicy, + AuthorizedCommunityAccess, BindingLeaseBound, BindingResolutionRequest, BindingSource, + BindingVersion, CapabilitySet, CurrentPolicyRequest, CurrentPolicyResolutionSink, + DirectBindingResolutionSink, EnrollmentMode, ExistingBindingResolutionSink, + FederatedAuthorityAdapter, FederatedAuthorization, FederatedIdentityRequirement, PolicyVersion, + ProviderAllow, ProviderAuthorizationClock, ProviderDecision, ProviderTimeout, Scope, + VerificationOnlyDisposition, VerificationStatusPolicy, VerifiedNostrProof, +}; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingEpoch, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + CLIENT_BINDING_EPOCH_HEADER, CLIENT_BINDING_STATUS_SUB_ID, }; use buzz_core::client_binding_status::{ ClientBindingStatusError, ClientBindingStatusFoldError, ClientBindingStatusInputV1, @@ -42,20 +52,26 @@ use buzz_relay::authorization_runtime::status::{ RelayClientBindingStatusIssuer, }; use buzz_relay::connection::OutboundData; -use buzz_relay::protocol::RelayMessage; +use buzz_relay::protocol::{ClientMessage, RelayMessage}; use buzz_relay::state::ConnectionManager; +use client_binding_status_session::{ + ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, +}; use futures::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, RelayUrl, Timestamp}; use serde_json::{json, Value}; use tokio::net::TcpListener; -use tokio::sync::{mpsc, Mutex as AsyncMutex}; +use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; use tokio::time::timeout; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::handshake::server::{ + Request as ServerRequest, Response as ServerResponse, +}; +use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; use uuid::Uuid; -const STATUS_SUBSCRIPTION: &str = "__buzz_client_binding_status_v1__"; - #[derive(Clone)] struct FixedClock(u64); @@ -261,30 +277,12 @@ impl DurableClientStatusRevisionSource for SyntheticRevisions { } async fn verification_only_disposition( - relay_url: &str, domain: CommunityId, author: &Keys, now: u64, + proof: VerifiedNostrProof, ) -> (VerificationOnlyDisposition, ClientStatusPrivacyKey) { let adapter = VerifiedEvidenceAdapter::new(); - let challenge = Uuid::new_v4().to_string(); - let auth_event = EventBuilder::auth( - challenge.clone(), - RelayUrl::parse(relay_url).expect("loopback relay URL is valid"), - ) - .sign_with_keys(author) - .expect("ephemeral author signs NIP-42 proof"); - let proof = adapter - .verify_nip42( - domain, - AuthTransport::RelayWebSocket, - &auth_event, - &challenge, - relay_url, - None, - ) - .expect("production verifier seals the loopback NIP-42 proof"); - let issuer = format!("https://{}.invalid", Uuid::new_v4()); let subject = Uuid::new_v4().to_string(); let assertion = adapter @@ -390,11 +388,10 @@ async fn verification_only_disposition( (disposition, privacy_key) } -fn register_authenticated_connection( +fn register_connection( connections: &ConnectionManager, connection_id: Uuid, domain: CommunityId, - author: &Keys, ) -> ( mpsc::Receiver, mpsc::Receiver, @@ -411,7 +408,6 @@ fn register_authenticated_connection( Arc::new(AsyncMutex::new(HashMap::new())), 3, ); - connections.set_authenticated_pubkey(connection_id, author.public_key().to_bytes().to_vec()); (rx, ctrl_rx) } @@ -451,7 +447,7 @@ async fn receive_status( socket: &mut tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, -) -> Event { +) -> (String, Event) { let message = timeout(Duration::from_secs(2), socket.next()) .await .expect("loopback status frame must not time out") @@ -462,23 +458,71 @@ async fn receive_status( }; let envelope: Value = serde_json::from_str(&text).expect("relay frame is JSON"); assert_eq!(envelope[0], "EVENT"); - assert_eq!(envelope[1], STATUS_SUBSCRIPTION); - Event::from_json(envelope[2].to_string()).expect("relay frame carries a Nostr event") + let event = + Event::from_json(envelope[2].to_string()).expect("relay frame carries a Nostr event"); + (text.to_string(), event) } -async fn round_trip( - sender: &mpsc::UnboundedSender, +async fn receive_transport_event( + expected_frames: &mpsc::UnboundedSender, socket: &mut tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, + subscription: &str, event: &Event, -) -> Event { - sender - .send(RelayMessage::event(STATUS_SUBSCRIPTION, event)) - .expect("loopback relay task remains live"); - let received = receive_status(socket).await; +) -> (String, Event) { + expected_frames + .send(RelayMessage::event(subscription, event)) + .expect("queue-drain adapter remains live"); + let (text, received) = receive_status(socket).await; assert_eq!(received.id, event.id, "wire event must be issuer-produced"); - received + (text, received) +} + +async fn enqueue_and_receive_event( + connections: &ConnectionManager, + connection_id: Uuid, + expected_frames: &mpsc::UnboundedSender, + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + subscription: &str, + event: &Event, +) -> (String, Event) { + let frame = RelayMessage::event(subscription, event); + expected_frames + .send(frame.clone()) + .expect("queue-drain adapter remains live"); + assert!( + connections.send_to(connection_id, frame), + "production ConnectionManager must enqueue the exact-connection frame" + ); + let (text, received) = receive_status(socket).await; + assert_eq!(received.id, event.id, "wire event must be sender-produced"); + (text, received) +} + +fn assert_current_projection( + update: Option, + author: &Keys, + epoch: &ClientBindingEpoch, + fresh_until: u64, +) { + let Some(ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey, + fresh_until: projected_fresh_until, + connection_epoch, + })) = update + else { + panic!("production J1 session must project current status"); + }; + assert_eq!(event_author_pubkey, author.public_key().to_hex()); + assert_eq!(projected_fresh_until, fresh_until); + assert_eq!(connection_epoch, epoch.as_str()); +} + +fn assert_clear(update: Option) { + assert!(matches!(update, Some(ProjectionUpdate::Clear))); } #[tokio::test] @@ -491,38 +535,132 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop .expect("loopback listener has an address"); assert_ne!(address.port(), 0, "OS must allocate a real ephemeral port"); let relay_url = format!("ws://{address}"); - let (frame_tx, mut frame_rx) = mpsc::unbounded_channel::(); + let now = Timestamp::now().as_secs(); + let relay = Keys::generate(); + let author = Keys::generate(); + let spoof = Keys::generate(); + let wrong_relay = Keys::generate(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let wrong_domain = CommunityId::from_uuid(Uuid::new_v4()); + let connection_id = Uuid::new_v4(); + let epoch = ClientBindingEpoch::from_random_bytes(rand::random()); + let challenge = Uuid::new_v4().to_string(); + let auth_event = EventBuilder::auth( + challenge.clone(), + RelayUrl::parse(&relay_url).expect("loopback relay URL is valid"), + ) + .sign_with_keys(&author) + .expect("ephemeral author signs NIP-42 proof"); + + let connections = Arc::new(ConnectionManager::new()); + let (mut outbound_rx, _ctrl_rx) = register_connection(&connections, connection_id, domain); + let (expected_frame_tx, mut expected_frame_rx) = mpsc::unbounded_channel::(); + let (epoch_header_tx, epoch_header_rx) = oneshot::channel::>(); + let (auth_proof_tx, auth_proof_rx) = oneshot::channel::(); + let server_relay_url = relay_url.clone(); + let server_challenge = challenge.clone(); let server = tokio::spawn(async move { let (tcp, peer) = listener.accept().await.expect("loopback client connects"); assert!(peer.ip().is_loopback(), "harness must remain loopback-only"); - let mut websocket = tokio_tungstenite::accept_async(tcp) - .await - .expect("loopback WebSocket upgrades"); + let mut epoch_header_tx = Some(epoch_header_tx); + let mut websocket = tokio_tungstenite::accept_hdr_async( + tcp, + move |request: &ServerRequest, response: ServerResponse| { + let value = request + .headers() + .get(CLIENT_BINDING_EPOCH_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + if let Some(sender) = epoch_header_tx.take() { + let _ = sender.send(value); + } + Ok(response) + }, + ) + .await + .expect("loopback WebSocket upgrades"); + + let auth_text = match websocket.next().await { + Some(Ok(Message::Text(text))) => text, + _ => panic!("first loopback client frame must be text AUTH"), + }; + let ClientMessage::Auth(event) = + ClientMessage::parse(&auth_text).expect("Buzz parser accepts real AUTH frame") + else { + panic!("first loopback client frame must parse as AUTH"); + }; + let proof = VerifiedEvidenceAdapter::new() + .verify_nip42( + domain, + AuthTransport::RelayWebSocket, + &event, + &server_challenge, + &server_relay_url, + None, + ) + .expect("Buzz verifier seals AUTH received over loopback"); + auth_proof_tx + .send(proof) + .expect("test driver awaits verified AUTH evidence"); + let mut sent = 0usize; - while let Some(frame) = frame_rx.recv().await { + while let Some(queued) = outbound_rx.recv().await { + let frame = expected_frame_rx + .recv() + .await + .expect("each production queue item has one test-visible oracle frame"); + // `OutboundData::release` is intentionally crate-private. Receiving + // and consuming this value proves the production manager/transport + // emitted before the test-only adapter sends its matching oracle. + drop(queued); websocket .send(Message::Text(frame.into())) .await - .expect("loopback status frame sends"); + .expect("queue-gated loopback frame sends"); sent += 1; } let _ = websocket.close(None).await; sent }); - let (mut socket, _) = tokio_tungstenite::connect_async(&relay_url) + + let mut request = relay_url + .as_str() + .into_client_request() + .expect("loopback WebSocket request is valid"); + request.headers_mut().insert( + CLIENT_BINDING_EPOCH_HEADER, + HeaderValue::from_str(epoch.as_str()).expect("canonical epoch is a valid header"), + ); + let (mut socket, _) = tokio_tungstenite::connect_async(request) .await .expect("real loopback WebSocket client connects"); + let received_epoch = epoch_header_rx + .await + .expect("loopback handshake reports epoch header") + .expect("native epoch header is present"); + assert_eq!( + ClientBindingEpoch::parse(&received_epoch).expect("server parses canonical epoch"), + epoch + ); + socket + .send(Message::Text( + json!([ + "AUTH", + serde_json::to_value(&auth_event).expect("AUTH serializes") + ]) + .to_string() + .into(), + )) + .await + .expect("real AUTH frame crosses loopback WebSocket"); + let proof = auth_proof_rx + .await + .expect("server returns exact verified AUTH evidence"); + assert_eq!(proof.actor_pubkey(), author.public_key()); + connections.set_authenticated_pubkey(connection_id, proof.actor_pubkey().to_bytes().to_vec()); - let now = Timestamp::now().as_secs(); - let relay = Keys::generate(); - let author = Keys::generate(); - let spoof = Keys::generate(); - let wrong_relay = Keys::generate(); - let domain = CommunityId::from_uuid(Uuid::new_v4()); - let wrong_domain = CommunityId::from_uuid(Uuid::new_v4()); - let connection_id = Uuid::new_v4(); let (disposition, privacy_key) = - verification_only_disposition(&relay_url, domain, &author, now).await; + verification_only_disposition(domain, &author, now, proof).await; let evidence = authoritative_evidence(&disposition, &privacy_key); let revisions = SyntheticRevisions::new(10); let issuer = RelayClientBindingStatusIssuer::new(&relay, &revisions, &privacy_key); @@ -531,10 +669,30 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop }) .expect("test-only complete approval constructs the production gate"); - let connections = Arc::new(ConnectionManager::new()); - let (_outbound_rx, _ctrl_rx) = - register_authenticated_connection(&connections, connection_id, domain, &author); let transport = ConnectionManagerClientStatusTransport::new(Arc::clone(&connections)); + + let bootstrap = + ClientBindingBootstrapInputV1::new(domain, author.public_key(), epoch.clone(), now) + .expect("authenticated connection scope creates bootstrap input") + .sign_with_relay_keys(&relay) + .expect("relay signs exact connection bootstrap"); + let mut session = + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + let (bootstrap_text, received_bootstrap) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, + &bootstrap, + ) + .await; + assert_eq!(received_bootstrap.id, bootstrap.id); + assert!(matches!( + session.consume_text(&bootstrap_text, now), + Some(ProjectionUpdate::Unchanged) + )); + let current_attempt = issuer .deliver_verification_only(&permit, &disposition, 1, None, connection_id, &transport) .await @@ -557,7 +715,11 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop // or resolved for another authorization domain. let wrong_author_connection = Uuid::new_v4(); let (_wrong_author_rx, _wrong_author_ctrl_rx) = - register_authenticated_connection(&connections, wrong_author_connection, domain, &spoof); + register_connection(&connections, wrong_author_connection, domain); + connections.set_authenticated_pubkey( + wrong_author_connection, + spoof.public_key().to_bytes().to_vec(), + ); let wrong_author_attempt = issuer .deliver_verification_only( &permit, @@ -572,11 +734,11 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop assert!(wrong_author_attempt.delivery_error().is_some()); let wrong_domain_connection = Uuid::new_v4(); - let (_wrong_domain_rx, _wrong_domain_ctrl_rx) = register_authenticated_connection( - &connections, + let (_wrong_domain_rx, _wrong_domain_ctrl_rx) = + register_connection(&connections, wrong_domain_connection, wrong_domain); + connections.set_authenticated_pubkey( wrong_domain_connection, - wrong_domain, - &author, + author.public_key().to_bytes().to_vec(), ); let wrong_domain_attempt = issuer .deliver_verification_only( @@ -592,7 +754,19 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop assert!(wrong_domain_attempt.delivery_error().is_some()); revisions.set(10); - let current = round_trip(&frame_tx, &mut socket, current_attempt.event()).await; + let (current_text, current) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + current_attempt.event(), + ) + .await; + assert_current_projection( + session.consume_text(¤t_text, now), + &author, + &epoch, + disposition.expires_at(), + ); let mut tracker = ClientBindingStatusTracker::new(relay.public_key(), domain, author.public_key()); assert_eq!( @@ -603,7 +777,16 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop assert_eq!(tracker.high_water_revision(), Some(10)); let malformed = raw_signed_event(&relay, KIND_CLIENT_BINDING_STATUS, "{".to_string(), now); - let malformed = round_trip(&frame_tx, &mut socket, &malformed).await; + let (malformed_text, malformed) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &malformed, + ) + .await; + assert_clear(session.consume_text(&malformed_text, now)); assert_eq!( tracker.accept(&malformed, now), Err(ClientBindingStatusFoldError::InvalidStatus( @@ -620,7 +803,16 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop unsupported_content.to_string(), now, ); - let unsupported = round_trip(&frame_tx, &mut socket, &unsupported).await; + let (unsupported_text, unsupported) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &unsupported, + ) + .await; + assert_clear(session.consume_text(&unsupported_text, now)); assert_eq!( tracker.accept(&unsupported, now), Err(ClientBindingStatusFoldError::InvalidStatus( @@ -641,7 +833,19 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop .expect("bounded wrong-relay status input") .sign_with_relay_keys(&wrong_relay) .expect("wrong relay still produces an authenticated Nostr event"); - let wrong_signer = round_trip(&frame_tx, &mut socket, &wrong_signer).await; + let (wrong_signer_text, wrong_signer) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &wrong_signer, + ) + .await; + assert!(matches!( + session.consume_text(&wrong_signer_text, now), + Some(ProjectionUpdate::Unchanged) + )); assert_eq!( tracker.accept(&wrong_signer, now), Err(ClientBindingStatusFoldError::InvalidStatus( @@ -662,7 +866,16 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop .expect("bounded mismatched-author status input") .sign_with_relay_keys(&relay) .expect("relay signs explicit mismatched-author test event"); - let author_mismatch = round_trip(&frame_tx, &mut socket, &author_mismatch).await; + let (author_mismatch_text, author_mismatch) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &author_mismatch, + ) + .await; + assert_clear(session.consume_text(&author_mismatch_text, now)); assert_eq!( tracker.accept(&author_mismatch, now), Err(ClientBindingStatusFoldError::InvalidStatus( @@ -683,7 +896,16 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop .expect("bounded mismatched-domain status input") .sign_with_relay_keys(&relay) .expect("relay signs explicit mismatched-domain test event"); - let domain_mismatch = round_trip(&frame_tx, &mut socket, &domain_mismatch).await; + let (domain_mismatch_text, domain_mismatch) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &domain_mismatch, + ) + .await; + assert_clear(session.consume_text(&domain_mismatch_text, now)); assert_eq!( tracker.accept(&domain_mismatch, now), Err(ClientBindingStatusFoldError::InvalidStatus( @@ -693,21 +915,41 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop // Neither mutable profile metadata nor a legacy NIP-85 assertion can // restore or rename the relay-authenticated status presentation. - for legacy in [ - raw_signed_event( - &spoof, - Kind::Metadata.as_u16() as u32, - json!({"name": format!("spoof-{}", Uuid::new_v4())}).to_string(), - now, + for (legacy, expected_clear) in [ + ( + raw_signed_event( + &spoof, + Kind::Metadata.as_u16() as u32, + json!({"name": format!("spoof-{}", Uuid::new_v4())}).to_string(), + now, + ), + false, ), - raw_signed_event( - &relay, - KIND_USER_TRUSTED_ASSERTION, - json!({"active": true, "label": format!("legacy-{}", Uuid::new_v4())}).to_string(), - now, + ( + raw_signed_event( + &relay, + KIND_USER_TRUSTED_ASSERTION, + json!({"active": true, "label": format!("legacy-{}", Uuid::new_v4())}).to_string(), + now, + ), + true, ), ] { - let legacy = round_trip(&frame_tx, &mut socket, &legacy).await; + let (legacy_text, legacy) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &legacy, + ) + .await; + let update = session.consume_text(&legacy_text, now); + if expected_clear { + assert_clear(update); + } else { + assert!(matches!(update, Some(ProjectionUpdate::Unchanged))); + } assert_eq!( tracker.accept(&legacy, now), Err(ClientBindingStatusFoldError::InvalidStatus( @@ -733,7 +975,14 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop ) .await .expect("production issuer delivers a strictly newer withdrawal"); - let withdrawal = round_trip(&frame_tx, &mut socket, &withdrawal).await; + let (withdrawal_text, withdrawal) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &withdrawal, + ) + .await; + assert_clear(session.consume_text(&withdrawal_text, now)); assert_eq!( tracker.accept(&withdrawal, now), Ok(ClientBindingStatusUpdate::Accepted) @@ -761,19 +1010,89 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop .expect("equal-revision conflict input is structurally valid") .sign_with_relay_keys(&relay) .expect("relay signs explicit conflict event"); - let equal_conflict = round_trip(&frame_tx, &mut socket, &equal_conflict).await; + let (equal_conflict_text, equal_conflict) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &equal_conflict, + ) + .await; + assert_clear(session.consume_text(&equal_conflict_text, now)); assert_eq!( tracker.accept(&equal_conflict, now), Err(ClientBindingStatusFoldError::ConflictingEqualRevision) ); - revisions.set(12); + // A trusted relay event in a malformed reserved outer frame must clear and + // consume its revision. Replaying the same event in an exact frame cannot + // restore; only a strictly newer issuer event may do so (J1 fail-closed + // high-water latch). + let trusted_invalid_current = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 8, + "opaque-trusted-invalid", + 12, + now, + disposition.expires_at(), + None, + ) + .expect("trusted-invalid inner status is structurally valid") + .sign_with_relay_keys(&relay) + .expect("trusted relay signs inner status"); + let trusted_invalid_frame = json!([ + "EVENT", + CLIENT_BINDING_STATUS_SUB_ID, + serde_json::to_value(&trusted_invalid_current).expect("status serializes"), + {"unexpected": true} + ]) + .to_string(); + expected_frame_tx + .send(trusted_invalid_frame.clone()) + .expect("queue-drain adapter remains live"); + assert!(connections.send_to(connection_id, trusted_invalid_frame)); + let (trusted_invalid_text, received_trusted_invalid) = receive_status(&mut socket).await; + assert_eq!(received_trusted_invalid.id, trusted_invalid_current.id); + assert_clear(session.consume_text(&trusted_invalid_text, now)); + assert_eq!(session.projected_fresh_until(), None); + + let (equal_replay_text, equal_replay) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &trusted_invalid_current, + ) + .await; + assert_eq!(equal_replay.id, trusted_invalid_current.id); + assert!(matches!( + session.consume_text(&equal_replay_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!(session.projected_fresh_until(), None); + + revisions.set(13); let restored_attempt = issuer .deliver_verification_only(&permit, &disposition, 1, None, connection_id, &transport) .await .expect("production issuer creates strictly newer restoration"); assert_eq!(restored_attempt.delivery_error(), None); - let restored = round_trip(&frame_tx, &mut socket, restored_attempt.event()).await; + let (restored_text, restored) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + restored_attempt.event(), + ) + .await; + assert_current_projection( + session.consume_text(&restored_text, now), + &author, + &epoch, + disposition.expires_at(), + ); assert_eq!( tracker.accept(&restored, now), Ok(ClientBindingStatusUpdate::Accepted) @@ -782,7 +1101,7 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop tracker.on_disconnect(); assert!(tracker.current_presentation(now).is_none()); - assert_eq!(tracker.high_water_revision(), Some(12)); + assert_eq!(tracker.high_water_revision(), Some(13)); assert_eq!( tracker.accept(&restored, now), Ok(ClientBindingStatusUpdate::Duplicate), @@ -790,12 +1109,32 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop ); assert!(tracker.current_presentation(now).is_none()); - revisions.set(13); + assert_clear(Some(session.disconnect())); + assert_eq!(session.projected_fresh_until(), None); + assert!(matches!( + session.consume_text(&restored_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!(session.projected_fresh_until(), None); + + revisions.set(14); let reconnect_attempt = issuer .deliver_verification_only(&permit, &disposition, 2, None, connection_id, &transport) .await .expect("reconnect obtains a newer production issuance"); - let reconnect = round_trip(&frame_tx, &mut socket, reconnect_attempt.event()).await; + let (reconnect_text, reconnect) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + reconnect_attempt.event(), + ) + .await; + assert_current_projection( + session.consume_text(&reconnect_text, now), + &author, + &epoch, + disposition.expires_at(), + ); assert_eq!( tracker.accept(&reconnect, now), Ok(ClientBindingStatusUpdate::Accepted) @@ -804,7 +1143,9 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop assert!(tracker .current_presentation(disposition.expires_at()) .is_none()); - assert_eq!(tracker.high_water_revision(), Some(13)); + assert_eq!(tracker.high_water_revision(), Some(14)); + assert_clear(Some(session.expire(disposition.expires_at()))); + assert_eq!(session.projected_fresh_until(), None); tracker.change_scope(relay.public_key(), wrong_domain, author.public_key()); assert_eq!(tracker.high_water_revision(), None); @@ -822,6 +1163,9 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop ClientBindingStatusTracker::new(relay.public_key(), domain, author.public_key()); assert!(restarted.current_presentation(now).is_none()); assert_eq!(restarted.high_water_revision(), None); + let restarted_session = + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + assert_eq!(restarted_session.projected_fresh_until(), None); assert_eq!(revisions.current_reads.load(Ordering::SeqCst), 5); assert_eq!(revisions.withdrawal_reads.load(Ordering::SeqCst), 1); @@ -838,43 +1182,17 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop })); drop(observed_scopes); - drop(frame_tx); + connections.deregister(connection_id); + drop(expected_frame_tx); let sent = server.await.expect("loopback relay task exits cleanly"); assert!( - sent >= 12, - "non-vacuity: all status cases crossed WebSocket" + sent >= 15, + "non-vacuity: AUTH/bootstrap/status cases crossed queue-gated WebSocket" ); } #[test] -fn composition_is_test_only_and_stock_production_remains_unwired() { - const HARNESS: &str = include_str!("j3c_current_binding_relay_harness.rs"); - const MAIN: &str = include_str!("../src/main.rs"); - const LIB: &str = include_str!("../src/lib.rs"); - const ROUTER: &str = include_str!("../src/router.rs"); - const STATE: &str = include_str!("../src/state.rs"); - - assert!(file!().contains("/tests/") || file!().starts_with("tests/")); - for required in [ - "TcpListener::bind(\"127.0.0.1:0\")", - "RelayClientBindingStatusIssuer::new", - "ConnectionManagerClientStatusTransport::new", - "ClientBindingStatusTracker::new", - "RelayMessage::event", - ] { - assert!( - HARNESS.contains(required), - "missing non-vacuity seam: {required}" - ); - } - for production_root in [MAIN, LIB, ROUTER, STATE] { - assert!(!production_root.contains("CompleteSyntheticApproval")); - assert!(!production_root.contains("SyntheticRevisions")); - assert!(!production_root.contains("j3c_current_binding_relay_harness")); - assert!(!production_root.contains("ProductionClientStatusRuntime::new")); - assert!(!production_root.contains("ClientStatusPresentationPermit::from_complete_stack")); - } - +fn presentation_gate_rejects_incomplete_review_evidence() { let incomplete = CompleteSyntheticApproval { reviewed_revision: "not-a-revision".to_string(), }; From e611e7a30752d24b76927696d755a6d3bad610aa Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:04:53 -0500 Subject: [PATCH 28/40] test(desktop): exercise relay binding projection flow Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../current_binding_status_native_flow.rs | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 desktop/src-tauri/tests/current_binding_status_native_flow.rs diff --git a/desktop/src-tauri/tests/current_binding_status_native_flow.rs b/desktop/src-tauri/tests/current_binding_status_native_flow.rs new file mode 100644 index 0000000000..93b9011e3e --- /dev/null +++ b/desktop/src-tauri/tests/current_binding_status_native_flow.rs @@ -0,0 +1,590 @@ +//! Real loopback transport coverage for the native current-binding projection. +//! +//! This integration target deliberately includes the production session fold instead of +//! recreating its validation or projection DTO. The relay half is synthetic and loopback-only; +//! every delivered event still crosses an actual WebSocket before the production fold sees it. + +#[path = "../src/client_binding_status_session.rs"] +mod client_binding_status_session; + +use std::{env, path::PathBuf, time::Duration}; + +use buzz_core_pkg::{ + client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingEpoch, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + CLIENT_BINDING_STATUS_SUB_ID, + }, + client_binding_status::ClientBindingStatusInputV1, + kind::{KIND_CLIENT_BINDING_STATUS, KIND_USER_TRUSTED_ASSERTION}, + CommunityId, +}; +use client_binding_status_session::{ + ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, +}; +use futures_util::{SinkExt, StreamExt}; +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::Serialize; +use serde_json::json; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + accept_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream, +}; +use uuid::Uuid; + +const NOW: u64 = 2_000_000_000; +const RECEIVE_TIMEOUT: Duration = Duration::from_secs(2); +const ORDINARY_SUB_ID: &str = "synthetic-ordinary-events"; + +type ClientSocket = WebSocketStream>; +type RelaySocket = WebSocketStream; + +#[derive(Serialize)] +struct ProjectionTrace { + version: u64, + steps: Vec, +} + +#[derive(Serialize)] +struct TraceStep { + case: &'static str, + // This is the production DTO, not a test-owned projection lookalike. + projection: Option, +} + +impl ProjectionTrace { + fn new() -> Self { + Self { + version: 1, + steps: Vec::new(), + } + } + + fn record(&mut self, case: &'static str, flow: &NativeFlow) { + self.steps.push(TraceStep { + case, + projection: flow.projection.clone(), + }); + } + + fn assert_contract_and_export_if_requested(&self) { + let value = serde_json::to_value(self).expect("production projection trace serializes"); + let steps = value["steps"].as_array().expect("trace steps are an array"); + for step in steps { + if let Some(projection) = step["projection"].as_object() { + let mut keys = projection.keys().map(String::as_str).collect::>(); + keys.sort_unstable(); + assert_eq!( + keys, + ["connectionEpoch", "eventAuthorPubkey", "freshUntil"], + "trace projection must remain the production current-only DTO", + ); + } + } + + let Some(raw_path) = env::var_os("BUZZ_J3C_PROJECTION_TRACE_OUT") else { + return; + }; + let path = PathBuf::from(raw_path); + assert!( + path.is_absolute(), + "BUZZ_J3C_PROJECTION_TRACE_OUT must be an absolute test-artifact path" + ); + let parent = path + .parent() + .expect("trace output must have a parent directory"); + std::fs::create_dir_all(parent).expect("create projection trace directory"); + let mut bytes = serde_json::to_vec_pretty(self).expect("serialize projection trace"); + bytes.push(b'\n'); + std::fs::write(&path, bytes).expect("write projection trace"); + } +} + +struct NativeFlow { + relay_socket: RelaySocket, + client_socket: ClientSocket, + session: ClientBindingStatusSession, + projection: Option, +} + +impl NativeFlow { + async fn connect( + trusted_relay_pubkey: PublicKey, + expected_author_pubkey: PublicKey, + epoch: ClientBindingEpoch, + ) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic relay to an OS-assigned loopback port"); + let address = listener.local_addr().expect("read synthetic relay address"); + assert!(address.ip().is_loopback()); + assert_ne!(address.port(), 0); + + let relay = tokio::spawn(async move { + let (stream, peer) = listener.accept().await.expect("accept native client"); + assert!(peer.ip().is_loopback()); + accept_async(stream) + .await + .expect("accept WebSocket upgrade") + }); + let (client_socket, response) = connect_async(format!("ws://{address}")) + .await + .expect("connect native loopback WebSocket"); + assert_eq!(response.status(), 101); + let relay_socket = relay.await.expect("join synthetic relay accept task"); + + Self { + relay_socket, + client_socket, + session: ClientBindingStatusSession::new( + trusted_relay_pubkey, + expected_author_pubkey, + epoch, + ), + projection: None, + } + } + + async fn send_reserved_event(&mut self, sub_id: &str, event: &Event, now: u64) { + assert!( + matches!( + sub_id, + CLIENT_BINDING_BOOTSTRAP_SUB_ID | CLIENT_BINDING_STATUS_SUB_ID + ), + "reserved helper requires an exact native-owned subscription id" + ); + let consumed = self.send_event(sub_id, event, now).await; + assert!(consumed, "production session must swallow reserved frames"); + } + + async fn send_ordinary_event(&mut self, event: &Event, now: u64) { + let consumed = self.send_event(ORDINARY_SUB_ID, event, now).await; + assert!( + !consumed, + "ordinary events must remain outside the status fold" + ); + } + + async fn send_event(&mut self, sub_id: &str, event: &Event, now: u64) -> bool { + let event_json = serde_json::to_value(event).expect("serialize synthetic Nostr event"); + let frame = json!(["EVENT", sub_id, event_json]).to_string(); + self.relay_socket + .send(Message::Text(frame.into())) + .await + .expect("send synthetic relay frame"); + + let message = tokio::time::timeout(RECEIVE_TIMEOUT, self.client_socket.next()) + .await + .expect("native socket receives relay frame before timeout") + .expect("native socket remains connected") + .expect("native socket receives a valid WebSocket message"); + let text = match message { + Message::Text(text) => text.to_string(), + other => panic!("expected relay text frame, received {other:?}"), + }; + let update = self.session.consume_text(&text, now); + let consumed = update.is_some(); + if let Some(update) = update { + self.apply(update); + } + consumed + } + + fn expire(&mut self, now: u64) { + let update = self.session.expire(now); + self.apply(update); + } + + async fn physical_disconnect(&mut self) { + self.relay_socket + .send(Message::Close(None)) + .await + .expect("relay closes physical WebSocket"); + let message = tokio::time::timeout(RECEIVE_TIMEOUT, self.client_socket.next()) + .await + .expect("native socket observes physical disconnect before timeout"); + assert!( + matches!(message, Some(Ok(Message::Close(_))) | None), + "native transport must observe a close frame or EOF" + ); + let update = self.session.disconnect(); + self.apply(update); + } + + fn apply(&mut self, update: ProjectionUpdate) { + match update { + ProjectionUpdate::Unchanged => {} + ProjectionUpdate::Clear => self.projection = None, + ProjectionUpdate::Current(projection) => self.projection = Some(projection), + } + } +} + +fn random_epoch() -> ClientBindingEpoch { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes).expect("generate synthetic connection epoch"); + ClientBindingEpoch::from_random_bytes(bytes) +} + +fn random_domain() -> CommunityId { + CommunityId::from_uuid(Uuid::new_v4()) +} + +fn bootstrap_event( + relay: &Keys, + domain: CommunityId, + author: PublicKey, + epoch: ClientBindingEpoch, + issued_at: u64, +) -> Event { + ClientBindingBootstrapInputV1::new(domain, author, epoch, issued_at) + .expect("construct synthetic bootstrap") + .sign_with_relay_keys(relay) + .expect("sign synthetic bootstrap") +} + +fn current_event( + relay: &Keys, + domain: CommunityId, + author: PublicKey, + revision: u64, + issued_at: u64, + fresh_until: u64, +) -> Event { + ClientBindingStatusInputV1::current( + domain, + author, + 1, + "policy.synthetic.example.invalid/v1", + revision, + issued_at, + fresh_until, + Some("Synthetic Example".to_string()), + ) + .expect("construct synthetic current status") + .sign_with_relay_keys(relay) + .expect("sign synthetic current status") +} + +fn withdrawal_event( + relay: &Keys, + domain: CommunityId, + author: PublicKey, + revision: u64, + issued_at: u64, + fresh_until: u64, +) -> Event { + ClientBindingStatusInputV1::withdrawn(domain, author, revision, issued_at, fresh_until) + .expect("construct synthetic withdrawal") + .sign_with_relay_keys(relay) + .expect("sign synthetic withdrawal") +} + +fn raw_status_event(relay: &Keys, content: &str, issued_at: u64) -> Event { + EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + content.to_string(), + ) + .tags([]) + .custom_created_at(Timestamp::from(issued_at)) + .sign_with_keys(relay) + .expect("sign synthetic raw status") +} + +async fn established_flow( + relay: &Keys, + author: PublicKey, + domain: CommunityId, + now: u64, +) -> NativeFlow { + let epoch = random_epoch(); + let mut flow = NativeFlow::connect(relay.public_key(), author, epoch.clone()).await; + let bootstrap = bootstrap_event(relay, domain, author, epoch, now); + flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, now) + .await; + let current = current_event(relay, domain, author, 1, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) + .await; + assert!(flow.projection.is_some()); + flow +} + +#[tokio::test] +async fn loopback_relay_drives_production_projection_and_trace() { + let relay = Keys::generate(); + let wrong_signer = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let profile_spoofer = Keys::generate(); + let domain = random_domain(); + let other_domain = random_domain(); + assert_ne!(domain, other_domain); + + let mut trace = ProjectionTrace::new(); + + // One physical connection exercises the revision fold as a sequence, proving that + // duplicate delivery retains current state while trusted-invalid evidence clears it. + let epoch = random_epoch(); + let mut flow = + NativeFlow::connect(relay.public_key(), author.public_key(), epoch.clone()).await; + let bootstrap = bootstrap_event(&relay, domain, author.public_key(), epoch.clone(), NOW); + flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, NOW) + .await; + trace.record("bootstrap", &flow); + + let current = current_event(&relay, domain, author.public_key(), 10, NOW, NOW + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, NOW) + .await; + let first_projection = + serde_json::to_value(&flow.projection).expect("serialize first production projection"); + let projected = flow.projection.as_ref().expect("current status projects"); + assert_eq!(projected.event_author_pubkey, author.public_key().to_hex()); + assert_eq!(projected.fresh_until, NOW + 120); + assert_eq!(projected.connection_epoch, epoch.as_str()); + trace.record("current", &flow); + + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, NOW) + .await; + assert_eq!( + serde_json::to_value(&flow.projection).expect("serialize duplicate projection"), + first_projection + ); + trace.record("duplicate", &flow); + + let equal_conflict = current_event(&relay, domain, author.public_key(), 10, NOW, NOW + 121); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &equal_conflict, NOW) + .await; + assert!(flow.projection.is_none()); + trace.record("equal-conflict", &flow); + + let rollback = current_event(&relay, domain, author.public_key(), 9, NOW, NOW + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &rollback, NOW) + .await; + assert!(flow.projection.is_none()); + trace.record("rollback", &flow); + + let newer = current_event(&relay, domain, author.public_key(), 11, NOW, NOW + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &newer, NOW) + .await; + assert!(flow.projection.is_some()); + trace.record("newer-restoration", &flow); + + let withdrawal = withdrawal_event(&relay, domain, author.public_key(), 12, NOW, NOW + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &withdrawal, NOW) + .await; + assert!(flow.projection.is_none()); + trace.record("withdrawal", &flow); + + let short_current = current_event(&relay, domain, author.public_key(), 13, NOW, NOW + 2); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &short_current, NOW) + .await; + assert!(flow.projection.is_some()); + flow.expire(NOW + 2); + assert!(flow.projection.is_none()); + trace.record("passive-expiry", &flow); + + let disconnect_current = + current_event(&relay, domain, author.public_key(), 14, NOW + 3, NOW + 123); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &disconnect_current, NOW + 3) + .await; + assert!(flow.projection.is_some()); + flow.physical_disconnect().await; + assert!(flow.projection.is_none()); + trace.record("disconnect", &flow); + + let mut reconnected = established_flow(&relay, author.public_key(), domain, NOW + 10).await; + trace.record("reconnect", &reconnected); + reconnected.physical_disconnect().await; + trace.record("logout", &reconnected); + + let mut restarted = established_flow(&relay, author.public_key(), domain, NOW + 20).await; + restarted.physical_disconnect().await; + trace.record("restart", &restarted); + + // A different physical relay connection starts empty even when the signer is reused. + let relay_epoch = random_epoch(); + let relay_scope = + NativeFlow::connect(relay.public_key(), author.public_key(), relay_epoch).await; + trace.record("relay-scope-change", &relay_scope); + + // Wrong-signer traffic is untrusted noise and cannot create or clear presentation. + let signer_epoch = random_epoch(); + let mut signer_scope = NativeFlow::connect( + wrong_signer.public_key(), + author.public_key(), + signer_epoch.clone(), + ) + .await; + let old_signer_bootstrap = + bootstrap_event(&relay, domain, author.public_key(), signer_epoch, NOW + 30); + signer_scope + .send_reserved_event( + CLIENT_BINDING_BOOTSTRAP_SUB_ID, + &old_signer_bootstrap, + NOW + 30, + ) + .await; + trace.record("signer-scope-change", &signer_scope); + + let author_epoch = random_epoch(); + let mut author_scope = NativeFlow::connect( + relay.public_key(), + other_author.public_key(), + author_epoch.clone(), + ) + .await; + let old_author_bootstrap = + bootstrap_event(&relay, domain, author.public_key(), author_epoch, NOW + 31); + author_scope + .send_reserved_event( + CLIENT_BINDING_BOOTSTRAP_SUB_ID, + &old_author_bootstrap, + NOW + 31, + ) + .await; + trace.record("author-scope-change", &author_scope); + + let domain_epoch = random_epoch(); + let mut domain_scope = NativeFlow::connect( + relay.public_key(), + author.public_key(), + domain_epoch.clone(), + ) + .await; + let domain_bootstrap = bootstrap_event( + &relay, + other_domain, + author.public_key(), + domain_epoch, + NOW + 32, + ); + domain_scope + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &domain_bootstrap, NOW + 32) + .await; + let old_domain_status = + current_event(&relay, domain, author.public_key(), 1, NOW + 32, NOW + 152); + domain_scope + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &old_domain_status, NOW + 32) + .await; + trace.record("domain-scope-change", &domain_scope); + + let old_epoch = random_epoch(); + let new_epoch = random_epoch(); + assert_ne!(old_epoch, new_epoch); + let mut epoch_scope = + NativeFlow::connect(relay.public_key(), author.public_key(), new_epoch).await; + let stale_epoch_bootstrap = + bootstrap_event(&relay, domain, author.public_key(), old_epoch, NOW + 33); + epoch_scope + .send_reserved_event( + CLIENT_BINDING_BOOTSTRAP_SUB_ID, + &stale_epoch_bootstrap, + NOW + 33, + ) + .await; + trace.record("epoch-scope-change", &epoch_scope); + + let mut malformed = established_flow(&relay, author.public_key(), domain, NOW + 40).await; + let malformed_status = raw_status_event(&relay, r#"{"version":1,"domain":"broken"}"#, NOW + 40); + malformed + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &malformed_status, NOW + 40) + .await; + trace.record("malformed-trusted", &malformed); + + let mut unsupported = established_flow(&relay, author.public_key(), domain, NOW + 41).await; + let unsupported_status = raw_status_event(&relay, r#"{"version":2}"#, NOW + 41); + unsupported + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &unsupported_status, NOW + 41) + .await; + trace.record("unsupported-version", &unsupported); + + let mut mismatched_author = + established_flow(&relay, author.public_key(), domain, NOW + 42).await; + let author_mismatch = current_event( + &relay, + domain, + other_author.public_key(), + 2, + NOW + 42, + NOW + 162, + ); + mismatched_author + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &author_mismatch, NOW + 42) + .await; + trace.record("author-mismatch", &mismatched_author); + + // Ordinary kind-0 and NIP-85 traffic crosses the same socket but never enters the + // reserved production fold, so neither can manufacture a current projection. + let mut legacy = + NativeFlow::connect(relay.public_key(), author.public_key(), random_epoch()).await; + let spoofed_profile = EventBuilder::new( + Kind::Metadata, + r#"{"display_name":"Spoofed Verified User","nip05":"spoof@identity.example.invalid"}"#, + ) + .sign_with_keys(&profile_spoofer) + .expect("sign synthetic profile spoof"); + legacy.send_ordinary_event(&spoofed_profile, NOW + 50).await; + trace.record("profile-spoof", &legacy); + + let subject = author.public_key().to_hex(); + let expiry = (NOW + 170).to_string(); + let nip85 = EventBuilder::new( + Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + String::new(), + ) + .tags([ + Tag::parse(["d", subject.as_str()]).expect("synthetic d tag"), + Tag::parse(["p", subject.as_str()]).expect("synthetic p tag"), + Tag::parse(["verified", "relay"]).expect("synthetic verified tag"), + Tag::parse(["active", "true"]).expect("synthetic active tag"), + Tag::parse(["expiration", expiry.as_str()]).expect("synthetic expiration tag"), + Tag::parse(["display_name", "Spoofed Legacy Assertion"]) + .expect("synthetic display-name tag"), + ]) + .sign_with_keys(&relay) + .expect("sign synthetic NIP-85 assertion"); + legacy.send_ordinary_event(&nip85, NOW + 50).await; + trace.record("nip85-no-fallback", &legacy); + + let cases = trace.steps.iter().map(|step| step.case).collect::>(); + assert_eq!( + cases, + [ + "bootstrap", + "current", + "duplicate", + "equal-conflict", + "rollback", + "newer-restoration", + "withdrawal", + "passive-expiry", + "disconnect", + "reconnect", + "logout", + "restart", + "relay-scope-change", + "signer-scope-change", + "author-scope-change", + "domain-scope-change", + "epoch-scope-change", + "malformed-trusted", + "unsupported-version", + "author-mismatch", + "profile-spoof", + "nip85-no-fallback", + ] + ); + for step in &trace.steps { + let expected_current = matches!( + step.case, + "current" | "duplicate" | "newer-restoration" | "reconnect" + ); + assert_eq!( + step.projection.is_some(), + expected_current, + "unexpected retained projection for {}", + step.case + ); + } + + trace.assert_contract_and_export_if_requested(); +} From b94b2ce4a690a0445dbbedd1d83d856b90c8b411 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:47:43 -0500 Subject: [PATCH 29/40] test(desktop): consume native binding projection trace Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../e2e/j3c/current-binding-status.spec.ts | 219 ++++++++++++++++++ .../e2e/j3c/currentBindingStatusTrace.ts | 178 ++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 desktop/tests/e2e/j3c/current-binding-status.spec.ts create mode 100644 desktop/tests/e2e/j3c/currentBindingStatusTrace.ts diff --git a/desktop/tests/e2e/j3c/current-binding-status.spec.ts b/desktop/tests/e2e/j3c/current-binding-status.spec.ts new file mode 100644 index 0000000000..7ee2845932 --- /dev/null +++ b/desktop/tests/e2e/j3c/current-binding-status.spec.ts @@ -0,0 +1,219 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../../helpers/bridge"; +import { + forwardTraceStep, + loadCurrentBindingStatusTrace, + traceStep, + type NativeCurrentProjection, +} from "./currentBindingStatusTrace"; + +const trace = loadCurrentBindingStatusTrace(); +const PROFILE_SPOOF_PREFIX = "profile-spoof-must-not-authorize"; +const PROFILE_NIP05_PREFIX = "profile-nip05-must-not-authorize"; + +async function waitForMockLiveSubscription(page: Page) { + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? false, + ), + ) + .toBe(true); +} + +async function waitForProjectionBridgeBootstrap(page: Page) { + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_COMMANDS__?.includes( + "get_current_binding_projection", + ), + ), + ) + .toBe(true); + + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }), + ); +} + +function otherSyntheticAuthor(projectedAuthors: ReadonlySet): string { + for (const identity of [TEST_IDENTITIES.bob, TEST_IDENTITIES.charlie]) { + if (!projectedAuthors.has(identity.pubkey)) return identity.pubkey; + } + throw new Error( + "Native trace unexpectedly contains both comparison authors.", + ); +} + +async function emitMessage( + page: Page, + input: { content: string; pubkey: string; createdAt: number }, +) { + await page.evaluate((message) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock live-message bridge is not installed."); + emit({ channelName: "general", ...message }); + }, input); +} + +async function expectOnlyAuthorBadge( + page: Page, + rows: ReadonlyMap, + projection: NativeCurrentProjection, +) { + const matchingRow = rows.get(projection.eventAuthorPubkey); + if (!matchingRow) { + throw new Error("No message row was created for the projected author."); + } + + const badge = matchingRow.getByTestId("current-relay-binding"); + await expect(badge).toHaveCount(1); + await expect(badge).toHaveAccessibleName("Current relay binding"); + await expect(page.getByTestId("current-relay-binding")).toHaveCount(1); + + for (const [author, row] of rows) { + if (author !== projection.eventAuthorPubkey) { + await expect(row.getByTestId("current-relay-binding")).toHaveCount(0); + } + } + + const badgeMarkup = ( + await badge.evaluate((element) => element.outerHTML) + ).toLowerCase(); + for (const hiddenValue of [ + projection.eventAuthorPubkey, + String(projection.freshUntil), + projection.connectionEpoch, + PROFILE_SPOOF_PREFIX, + PROFILE_NIP05_PREFIX, + "eventauthorpubkey", + "freshuntil", + "connectionepoch", + ]) { + expect(badgeMarkup).not.toContain(hiddenValue.toLowerCase()); + } +} + +async function expectNoLegacyTrustPresentation(page: Page) { + await expect(page.getByTestId("relay-verified-identity")).toHaveCount(0); + await expect( + page.locator('[aria-label^="Relay-verified identity"]'), + ).toHaveCount(0); + await expect(page.getByText("Binding active", { exact: false })).toHaveCount( + 0, + ); + await expect(page.getByText("Verified as", { exact: false })).toHaveCount(0); +} + +test("Rust native-flow projections drive exact-author lifecycle presentation", async ({ + page, +}) => { + const currentProjections = trace.steps.flatMap((step) => + step.projection === null ? [] : [step.projection], + ); + const projectedAuthors = new Set( + currentProjections.map((projection) => projection.eventAuthorPubkey), + ); + const expiryProjection = currentProjections.reduce((earliest, projection) => + projection.freshUntil < earliest.freshUntil ? projection : earliest, + ); + const clockStartSeconds = expiryProjection.freshUntil - 2; + if (clockStartSeconds <= 0) { + throw new Error( + "Native trace freshUntil is too small for expiry coverage.", + ); + } + + await page.clock.install({ time: clockStartSeconds * 1_000 }); + await installMockBridge(page, { + searchProfiles: [...projectedAuthors].map((pubkey, index) => ({ + pubkey, + displayName: `${PROFILE_SPOOF_PREFIX}-${index}`, + nip05Handle: `${PROFILE_NIP05_PREFIX}-${index}@example.invalid`, + })), + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page); + await waitForProjectionBridgeBootstrap(page); + + const rows = new Map(); + let createdAt = clockStartSeconds - projectedAuthors.size - 1; + for (const [index, pubkey] of [...projectedAuthors].entries()) { + const content = `Native projection author ${index}`; + await emitMessage(page, { content, pubkey, createdAt: createdAt++ }); + const row = page.getByTestId("message-row").filter({ hasText: content }); + await expect(row).toBeVisible(); + await expect(row.getByTestId("message-author")).toContainText( + `${PROFILE_SPOOF_PREFIX}-${index}`, + ); + rows.set(pubkey, row); + } + + const otherAuthor = otherSyntheticAuthor(projectedAuthors); + const otherContent = "Non-projected comparison author"; + await emitMessage(page, { + content: otherContent, + pubkey: otherAuthor, + createdAt, + }); + const otherRow = page + .getByTestId("message-row") + .filter({ hasText: otherContent }); + await expect(otherRow).toBeVisible(); + rows.set(otherAuthor, otherRow); + + for (const step of trace.steps) { + await forwardTraceStep(page, step); + if (step.projection === null) { + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); + } else { + await expectOnlyAuthorBadge(page, rows, step.projection); + } + } + + // These native lifecycle outputs must all clear presentation. Naming them + // explicitly keeps this browser layer non-vacuous if the trace grows later. + for (const caseName of [ + "withdrawal", + "passive-expiry", + "disconnect", + "logout", + "restart", + "relay-scope-change", + "signer-scope-change", + "author-scope-change", + "domain-scope-change", + "epoch-scope-change", + "profile-spoof", + "nip85-no-fallback", + ] as const) { + expect(traceStep(trace, caseName).projection).toBeNull(); + } + expect(traceStep(trace, "reconnect").projection).not.toBeNull(); + await expectNoLegacyTrustPresentation(page); + + // Re-deliver an unchanged DTO produced by Rust while the browser clock is + // before its deadline, then advance to the exclusive boundary. No later + // trace event, render fixture, or navigation clears it. + const expiryStep = trace.steps.find( + (step) => step.projection === expiryProjection, + ); + if (!expiryStep) throw new Error("Expiry projection is absent from trace."); + await forwardTraceStep(page, expiryStep); + await expectOnlyAuthorBadge(page, rows, expiryProjection); + await page.clock.fastForward( + (expiryProjection.freshUntil - clockStartSeconds) * 1_000, + ); + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); + await expectNoLegacyTrustPresentation(page); +}); diff --git a/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts b/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts new file mode 100644 index 0000000000..9828370318 --- /dev/null +++ b/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts @@ -0,0 +1,178 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; + +import type { Page } from "@playwright/test"; + +import { CURRENT_PROJECTION_EVENT } from "../../../src/features/binding-status/CurrentProjectionBridge"; +import type { CurrentProjection } from "../../../src/features/binding-status/currentProjectionStore"; + +const TRACE_ENV = "BUZZ_J3C_PROJECTION_TRACE"; +const LOWERCASE_HEX_256 = /^[0-9a-f]{64}$/; + +export const CURRENT_BINDING_TRACE_CASES = [ + "bootstrap", + "current", + "duplicate", + "equal-conflict", + "rollback", + "newer-restoration", + "withdrawal", + "passive-expiry", + "disconnect", + "reconnect", + "logout", + "restart", + "relay-scope-change", + "signer-scope-change", + "author-scope-change", + "domain-scope-change", + "epoch-scope-change", + "malformed-trusted", + "unsupported-version", + "author-mismatch", + "profile-spoof", + "nip85-no-fallback", +] as const; + +const CASES_WITH_CURRENT_PROJECTION = new Set([ + "current", + "duplicate", + "newer-restoration", + "reconnect", +]); + +export type CurrentBindingTraceCase = + (typeof CURRENT_BINDING_TRACE_CASES)[number]; + +export type NativeCurrentProjection = CurrentProjection; + +export type CurrentBindingTraceStep = Readonly<{ + case: CurrentBindingTraceCase; + projection: NativeCurrentProjection | null; +}>; + +export type CurrentBindingStatusTrace = Readonly<{ + version: 1; + steps: readonly CurrentBindingTraceStep[]; +}>; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: string[]) { + const actual = Object.keys(value).sort(); + const sortedExpected = [...expected].sort(); + return ( + actual.length === sortedExpected.length && + actual.every((key, index) => key === sortedExpected[index]) + ); +} + +function isCurrentProjection(value: unknown): value is NativeCurrentProjection { + if ( + !isRecord(value) || + !hasExactKeys(value, ["connectionEpoch", "eventAuthorPubkey", "freshUntil"]) + ) { + return false; + } + + return ( + typeof value.eventAuthorPubkey === "string" && + LOWERCASE_HEX_256.test(value.eventAuthorPubkey) && + typeof value.freshUntil === "number" && + Number.isSafeInteger(value.freshUntil) && + value.freshUntil > 0 && + typeof value.connectionEpoch === "string" && + LOWERCASE_HEX_256.test(value.connectionEpoch) + ); +} + +function parseTrace(value: unknown, path: string): CurrentBindingStatusTrace { + if (!isRecord(value) || !hasExactKeys(value, ["steps", "version"])) { + throw new Error(`${path} is not an exact J3C projection trace object.`); + } + if (value.version !== 1 || !Array.isArray(value.steps)) { + throw new Error(`${path} must contain trace version 1 and a steps array.`); + } + if (value.steps.length !== CURRENT_BINDING_TRACE_CASES.length) { + throw new Error( + `${path} must contain exactly ${CURRENT_BINDING_TRACE_CASES.length} trace steps.`, + ); + } + + for (const [index, expectedCase] of CURRENT_BINDING_TRACE_CASES.entries()) { + const step = value.steps[index]; + if (!isRecord(step) || !hasExactKeys(step, ["case", "projection"])) { + throw new Error(`${path} step ${index} is not an exact trace step.`); + } + if (step.case !== expectedCase) { + throw new Error( + `${path} step ${index} must be case ${expectedCase}, received ${String(step.case)}.`, + ); + } + + const expectsCurrent = CASES_WITH_CURRENT_PROJECTION.has(expectedCase); + if ( + (expectsCurrent && !isCurrentProjection(step.projection)) || + (!expectsCurrent && step.projection !== null) + ) { + throw new Error( + `${path} case ${expectedCase} has an invalid retained projection.`, + ); + } + } + + // Return the parsed objects themselves. The Playwright boundary forwards the + // native DTO without rebuilding, enriching, or substituting it. + return value as CurrentBindingStatusTrace; +} + +export function loadCurrentBindingStatusTrace(): CurrentBindingStatusTrace { + const path = process.env[TRACE_ENV]; + if (!path) { + throw new Error( + `${TRACE_ENV} is required and must name the Rust native-flow trace.`, + ); + } + if (!isAbsolute(path)) { + throw new Error(`${TRACE_ENV} must be an absolute path; received ${path}.`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + const reason = error instanceof Error ? error.message : "unknown error"; + throw new Error(`Unable to read ${TRACE_ENV} at ${path}: ${reason}.`); + } + return parseTrace(parsed, path); +} + +export function traceStep( + trace: CurrentBindingStatusTrace, + caseName: CurrentBindingTraceCase, +): CurrentBindingTraceStep { + const step = trace.steps.find((candidate) => candidate.case === caseName); + if (!step) { + throw new Error(`Native projection trace is missing case ${caseName}.`); + } + return step; +} + +export async function forwardTraceStep( + page: Page, + step: CurrentBindingTraceStep, +): Promise { + await page.evaluate( + async ({ eventName, projection }) => { + const emit = window.__BUZZ_E2E_EMIT_TAURI_EVENT__; + if (!emit) throw new Error("Mock Tauri event bridge is not installed."); + await emit(eventName, projection); + }, + { + eventName: CURRENT_PROJECTION_EVENT, + projection: step.projection, + }, + ); +} From e271b8d93b3e682114a175ab23746630eab42f9d Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:48:48 -0500 Subject: [PATCH 30/40] test(desktop): isolate native trace browser flow Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- ...atus.spec.ts => current-binding-status-native-trace.spec.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename desktop/tests/e2e/j3c/{current-binding-status.spec.ts => current-binding-status-native-trace.spec.ts} (98%) diff --git a/desktop/tests/e2e/j3c/current-binding-status.spec.ts b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts similarity index 98% rename from desktop/tests/e2e/j3c/current-binding-status.spec.ts rename to desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts index 7ee2845932..61b3e4bd2e 100644 --- a/desktop/tests/e2e/j3c/current-binding-status.spec.ts +++ b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts @@ -113,7 +113,7 @@ async function expectNoLegacyTrustPresentation(page: Page) { await expect(page.getByText("Verified as", { exact: false })).toHaveCount(0); } -test("Rust native-flow projections drive exact-author lifecycle presentation", async ({ +test("Rust native-flow trace drives exact-author lifecycle presentation", async ({ page, }) => { const currentProjections = trace.steps.flatMap((step) => From 5736c82ba4232343fff7d3a6d14bfa8167ea0a50 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:58:04 -0500 Subject: [PATCH 31/40] test(desktop): make trace clears non-vacuous Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- ...urrent-binding-status-native-trace.spec.ts | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts index 61b3e4bd2e..2f7dd1a767 100644 --- a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts +++ b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts @@ -9,8 +9,8 @@ import { } from "./currentBindingStatusTrace"; const trace = loadCurrentBindingStatusTrace(); -const PROFILE_SPOOF_PREFIX = "profile-spoof-must-not-authorize"; -const PROFILE_NIP05_PREFIX = "profile-nip05-must-not-authorize"; +const LEGACY_VERIFIED_NAME_MARKER = "legacy-verified-name-must-not-authorize"; +const LEGACY_ALIAS_MARKER = "legacy-relay-alias-must-not-authorize"; async function waitForMockLiveSubscription(page: Page) { await expect @@ -92,8 +92,8 @@ async function expectOnlyAuthorBadge( projection.eventAuthorPubkey, String(projection.freshUntil), projection.connectionEpoch, - PROFILE_SPOOF_PREFIX, - PROFILE_NIP05_PREFIX, + LEGACY_VERIFIED_NAME_MARKER, + LEGACY_ALIAS_MARKER, "eventauthorpubkey", "freshuntil", "connectionepoch", @@ -125,6 +125,11 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async const expiryProjection = currentProjections.reduce((earliest, projection) => projection.freshUntil < earliest.freshUntil ? projection : earliest, ); + const activationStep = traceStep(trace, "current"); + const activationProjection = activationStep.projection; + if (activationProjection === null) { + throw new Error("Native current trace step must contain a projection."); + } const clockStartSeconds = expiryProjection.freshUntil - 2; if (clockStartSeconds <= 0) { throw new Error( @@ -136,8 +141,8 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async await installMockBridge(page, { searchProfiles: [...projectedAuthors].map((pubkey, index) => ({ pubkey, - displayName: `${PROFILE_SPOOF_PREFIX}-${index}`, - nip05Handle: `${PROFILE_NIP05_PREFIX}-${index}@example.invalid`, + displayName: `${LEGACY_VERIFIED_NAME_MARKER}-${index}`, + nip05Handle: `${LEGACY_ALIAS_MARKER}-${index}@example.invalid`, })), }); await page.goto("/"); @@ -154,7 +159,7 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async const row = page.getByTestId("message-row").filter({ hasText: content }); await expect(row).toBeVisible(); await expect(row.getByTestId("message-author")).toContainText( - `${PROFILE_SPOOF_PREFIX}-${index}`, + `${LEGACY_VERIFIED_NAME_MARKER}-${index}`, ); rows.set(pubkey, row); } @@ -173,14 +178,50 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async rows.set(otherAuthor, otherRow); for (const step of trace.steps) { - await forwardTraceStep(page, step); - if (step.projection === null) { - await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); - } else { - await expectOnlyAuthorBadge(page, rows, step.projection); - } + if (step.case === "passive-expiry") continue; + + await test.step(`${step.case} projects its retained browser state`, async () => { + if (step.projection === null) { + // Every clear transition starts from a visible Rust-produced current + // projection so a pre-cleared store can never make the assertion pass. + await forwardTraceStep(page, activationStep); + await expectOnlyAuthorBadge(page, rows, activationProjection); + } + + await forwardTraceStep(page, step); + if (step.projection === null) { + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); + } else { + await expectOnlyAuthorBadge(page, rows, step.projection); + } + }); } + // The existing mock profile seed reaches ordinary displayName/NIP-05 fields + // but intentionally exposes no dormant verifiedName field. Prove those + // legacy-looking markers cannot enter trust-specific badge or panel UI. + const activationRow = rows.get(activationProjection.eventAuthorPubkey); + if (!activationRow) throw new Error("Activation author row is absent."); + await activationRow.getByRole("button").first().click(); + const profilePanel = page.getByTestId("user-profile-panel"); + await expect(profilePanel).toBeVisible(); + await expect(profilePanel).toContainText(LEGACY_VERIFIED_NAME_MARKER); + await expect(profilePanel).toContainText(LEGACY_ALIAS_MARKER); + await expect(profilePanel.getByTestId("relay-verified-identity")).toHaveCount( + 0, + ); + await expect( + profilePanel.locator('[aria-label^="Relay-verified identity"]'), + ).toHaveCount(0); + await expect( + profilePanel.getByText("Binding active", { exact: false }), + ).toHaveCount(0); + await expect( + profilePanel.getByText("Verified as", { exact: false }), + ).toHaveCount(0); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(profilePanel).toHaveCount(0); + // These native lifecycle outputs must all clear presentation. Naming them // explicitly keeps this browser layer non-vacuous if the trace grows later. for (const caseName of [ @@ -202,7 +243,7 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async expect(traceStep(trace, "reconnect").projection).not.toBeNull(); await expectNoLegacyTrustPresentation(page); - // Re-deliver an unchanged DTO produced by Rust while the browser clock is + // Deliver an unchanged DTO produced by Rust while the browser clock is // before its deadline, then advance to the exclusive boundary. No later // trace event, render fixture, or navigation clears it. const expiryStep = trace.steps.find( From 4f75529dda95a8cef1ea6c3e331f847f8ce8dade Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:45:17 -0500 Subject: [PATCH 32/40] test(relay): bind harness to signed auth scope Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../connection/j3c_current_binding_wire.rs | 2 +- .../j3c_current_binding_relay_harness.rs | 108 ++++++++---------- 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs index 765a5bfc90..1fe68f162b 100644 --- a/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs +++ b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs @@ -127,7 +127,7 @@ async fn production_outbound_bytes_cross_loopback_into_native_status_session() { let relay = Keys::generate(); let author = Keys::generate(); let domain = CommunityId::from_uuid(Uuid::new_v4()); - let epoch = ClientBindingEpoch::from_random_bytes(rand::random()); + let epoch = ClientBindingEpoch::new_v4(); let connection_id = Uuid::new_v4(); let now = Timestamp::now().as_secs(); let fresh_until = now + 120; diff --git a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs index 0a6fb65149..9312d54b35 100644 --- a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs +++ b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs @@ -35,8 +35,8 @@ use buzz_auth::{ VerificationOnlyDisposition, VerificationStatusPolicy, VerifiedNostrProof, }; use buzz_core::client_binding_bootstrap::{ - ClientBindingBootstrapInputV1, ClientBindingEpoch, CLIENT_BINDING_BOOTSTRAP_SUB_ID, - CLIENT_BINDING_EPOCH_HEADER, CLIENT_BINDING_STATUS_SUB_ID, + ClientBindingBootstrapInputV1, ClientBindingEpoch, ClientBindingScopeV1, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_SCOPE_TAG, CLIENT_BINDING_STATUS_SUB_ID, }; use buzz_core::client_binding_status::{ ClientBindingStatusError, ClientBindingStatusFoldError, ClientBindingStatusInputV1, @@ -58,16 +58,11 @@ use client_binding_status_session::{ ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, }; use futures::{SinkExt, StreamExt}; -use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, RelayUrl, Timestamp}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; use serde_json::{json, Value}; use tokio::net::TcpListener; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; use tokio::time::timeout; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::handshake::server::{ - Request as ServerRequest, Response as ServerResponse, -}; -use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -543,42 +538,38 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop let domain = CommunityId::from_uuid(Uuid::new_v4()); let wrong_domain = CommunityId::from_uuid(Uuid::new_v4()); let connection_id = Uuid::new_v4(); - let epoch = ClientBindingEpoch::from_random_bytes(rand::random()); + let epoch = ClientBindingEpoch::new_v4(); let challenge = Uuid::new_v4().to_string(); - let auth_event = EventBuilder::auth( - challenge.clone(), - RelayUrl::parse(&relay_url).expect("loopback relay URL is valid"), - ) - .sign_with_keys(&author) - .expect("ephemeral author signs NIP-42 proof"); + let auth_event = EventBuilder::new(Kind::Custom(22242), "") + .tags([ + Tag::parse(vec!["relay", relay_url.as_str()]).expect("loopback relay tag is valid"), + Tag::parse(vec!["challenge", challenge.as_str()]) + .expect("ephemeral challenge tag is valid"), + Tag::parse(vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + epoch.as_str().to_string(), + relay.public_key().to_hex(), + ]) + .expect("native status scope tag is valid"), + ]) + .sign_with_keys(&author) + .expect("ephemeral author signs scoped NIP-42 proof"); let connections = Arc::new(ConnectionManager::new()); let (mut outbound_rx, _ctrl_rx) = register_connection(&connections, connection_id, domain); let (expected_frame_tx, mut expected_frame_rx) = mpsc::unbounded_channel::(); - let (epoch_header_tx, epoch_header_rx) = oneshot::channel::>(); - let (auth_proof_tx, auth_proof_rx) = oneshot::channel::(); + let (auth_proof_tx, auth_proof_rx) = + oneshot::channel::<(VerifiedNostrProof, ClientBindingScopeV1)>(); let server_relay_url = relay_url.clone(); let server_challenge = challenge.clone(); + let expected_relay_signer = relay.public_key(); let server = tokio::spawn(async move { let (tcp, peer) = listener.accept().await.expect("loopback client connects"); assert!(peer.ip().is_loopback(), "harness must remain loopback-only"); - let mut epoch_header_tx = Some(epoch_header_tx); - let mut websocket = tokio_tungstenite::accept_hdr_async( - tcp, - move |request: &ServerRequest, response: ServerResponse| { - let value = request - .headers() - .get(CLIENT_BINDING_EPOCH_HEADER) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - if let Some(sender) = epoch_header_tx.take() { - let _ = sender.send(value); - } - Ok(response) - }, - ) - .await - .expect("loopback WebSocket upgrades"); + let mut websocket = tokio_tungstenite::accept_async(tcp) + .await + .expect("loopback WebSocket upgrades"); let auth_text = match websocket.next().await { Some(Ok(Message::Text(text))) => text, @@ -599,8 +590,11 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop None, ) .expect("Buzz verifier seals AUTH received over loopback"); + let status_scope = ClientBindingScopeV1::from_verified_auth_event(&event) + .expect("verified AUTH carries one exact signed status scope"); + assert_eq!(status_scope.relay_signer(), expected_relay_signer); auth_proof_tx - .send(proof) + .send((proof, status_scope)) .expect("test driver awaits verified AUTH evidence"); let mut sent = 0usize; @@ -623,25 +617,9 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop sent }); - let mut request = relay_url - .as_str() - .into_client_request() - .expect("loopback WebSocket request is valid"); - request.headers_mut().insert( - CLIENT_BINDING_EPOCH_HEADER, - HeaderValue::from_str(epoch.as_str()).expect("canonical epoch is a valid header"), - ); - let (mut socket, _) = tokio_tungstenite::connect_async(request) + let (mut socket, _) = tokio_tungstenite::connect_async(&relay_url) .await .expect("real loopback WebSocket client connects"); - let received_epoch = epoch_header_rx - .await - .expect("loopback handshake reports epoch header") - .expect("native epoch header is present"); - assert_eq!( - ClientBindingEpoch::parse(&received_epoch).expect("server parses canonical epoch"), - epoch - ); socket .send(Message::Text( json!([ @@ -653,10 +631,12 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop )) .await .expect("real AUTH frame crosses loopback WebSocket"); - let proof = auth_proof_rx + let (proof, status_scope) = auth_proof_rx .await .expect("server returns exact verified AUTH evidence"); assert_eq!(proof.actor_pubkey(), author.public_key()); + assert_eq!(status_scope.connection_epoch(), &epoch); + assert_eq!(status_scope.relay_signer(), relay.public_key()); connections.set_authenticated_pubkey(connection_id, proof.actor_pubkey().to_bytes().to_vec()); let (disposition, privacy_key) = @@ -671,13 +651,21 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop let transport = ConnectionManagerClientStatusTransport::new(Arc::clone(&connections)); - let bootstrap = - ClientBindingBootstrapInputV1::new(domain, author.public_key(), epoch.clone(), now) - .expect("authenticated connection scope creates bootstrap input") - .sign_with_relay_keys(&relay) - .expect("relay signs exact connection bootstrap"); - let mut session = - ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + let authenticated_epoch = status_scope.connection_epoch().clone(); + let bootstrap = ClientBindingBootstrapInputV1::new( + domain, + author.public_key(), + authenticated_epoch.clone(), + now, + ) + .expect("authenticated connection scope creates bootstrap input") + .sign_with_relay_keys(&relay) + .expect("relay signs exact connection bootstrap"); + let mut session = ClientBindingStatusSession::new( + relay.public_key(), + author.public_key(), + authenticated_epoch, + ); let (bootstrap_text, received_bootstrap) = enqueue_and_receive_event( &connections, connection_id, From 7a3493e21b5eb91d7b9b2908bdc100309a743672 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:49:08 -0500 Subject: [PATCH 33/40] test(desktop): drive corrected binding status seam Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../current_binding_status_native_flow.rs | 597 +++++++++++------- 1 file changed, 372 insertions(+), 225 deletions(-) diff --git a/desktop/src-tauri/tests/current_binding_status_native_flow.rs b/desktop/src-tauri/tests/current_binding_status_native_flow.rs index 93b9011e3e..90fac422b7 100644 --- a/desktop/src-tauri/tests/current_binding_status_native_flow.rs +++ b/desktop/src-tauri/tests/current_binding_status_native_flow.rs @@ -1,42 +1,113 @@ //! Real loopback transport coverage for the native current-binding projection. //! -//! This integration target deliberately includes the production session fold instead of -//! recreating its validation or projection DTO. The relay half is synthetic and loopback-only; -//! every delivered event still crosses an actual WebSocket before the production fold sees it. +//! This target composes the production native WebSocket/session manager with a synthetic, +//! loopback-only relay. The test owns neither a projection fold nor a browser-state fixture: +//! every trace value comes back through the production projection channel and getter. #[path = "../src/client_binding_status_session.rs"] mod client_binding_status_session; -use std::{env, path::PathBuf, time::Duration}; +mod app_state { + use nostr::Keys; + + pub(crate) struct AppState { + keys: Keys, + relay_url: String, + } + + impl AppState { + pub(crate) fn synthetic(keys: Keys, relay_url: String) -> Self { + Self { keys, relay_url } + } + + pub(crate) fn signing_keys(&self) -> Result { + Ok(self.keys.clone()) + } + + pub(crate) fn relay_url(&self) -> &str { + &self.relay_url + } + } +} + +mod relay { + pub(crate) fn relay_ws_url_with_override(state: &crate::app_state::AppState) -> String { + state.relay_url().to_owned() + } +} + +mod egress_guard { + pub(crate) fn assert_no_key_backup(_: &str, _: &str) -> Result<(), String> { + Ok(()) + } + + pub(crate) fn assert_no_key_backup_bytes(_: &[u8], _: &str) -> Result<(), String> { + Ok(()) + } +} + +mod native_websocket { + include!("../src/native_websocket.rs"); + + pub(super) async fn connect_status_for_test( + manager: &WebSocketManager, + state: &crate::app_state::AppState, + url: String, + on_message: Channel, + on_projection: Channel, + ) -> Result { + connect_internal(manager, state, url, on_message, Some(on_projection)).await + } + + pub(super) async fn current_projection_for_test( + manager: &WebSocketManager, + ) -> Option { + manager.projection.lock().await.current.clone() + } + + pub(super) async fn connection_present_for_test(manager: &WebSocketManager, id: Id) -> bool { + manager.connections.lock().await.contains_key(&id) + } + + pub(super) fn unix_now_for_test() -> u64 { + unix_now() + } +} + +use std::{ + env, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; use buzz_core_pkg::{ client_binding_bootstrap::{ - ClientBindingBootstrapInputV1, ClientBindingEpoch, CLIENT_BINDING_BOOTSTRAP_SUB_ID, - CLIENT_BINDING_STATUS_SUB_ID, + ClientBindingBootstrapInputV1, ClientBindingEpoch, ClientBindingScopeV1, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_SCOPE_TAG, CLIENT_BINDING_STATUS_SUB_ID, }, client_binding_status::ClientBindingStatusInputV1, kind::{KIND_CLIENT_BINDING_STATUS, KIND_USER_TRUSTED_ASSERTION}, CommunityId, }; -use client_binding_status_session::{ - ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, -}; +use client_binding_status_session::CurrentProjection; use futures_util::{SinkExt, StreamExt}; -use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp}; +use native_websocket::{WebSocketManager, WebSocketMessage}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp}; use serde::Serialize; use serde_json::json; -use tokio::net::TcpStream; -use tokio_tungstenite::{ - accept_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream, -}; +use tauri::ipc::{Channel, InvokeResponseBody}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::{accept_async, tungstenite::Message, WebSocketStream}; use uuid::Uuid; -const NOW: u64 = 2_000_000_000; const RECEIVE_TIMEOUT: Duration = Duration::from_secs(2); const ORDINARY_SUB_ID: &str = "synthetic-ordinary-events"; -type ClientSocket = WebSocketStream>; -type RelaySocket = WebSocketStream; +type RelaySocket = WebSocketStream; #[derive(Serialize)] struct ProjectionTrace { @@ -59,10 +130,10 @@ impl ProjectionTrace { } } - fn record(&mut self, case: &'static str, flow: &NativeFlow) { + async fn record(&mut self, case: &'static str, flow: &NativeFlow) { self.steps.push(TraceStep { case, - projection: flow.projection.clone(), + projection: flow.projection().await, }); } @@ -101,17 +172,15 @@ impl ProjectionTrace { struct NativeFlow { relay_socket: RelaySocket, - client_socket: ClientSocket, - session: ClientBindingStatusSession, - projection: Option, + manager: WebSocketManager, + id: u32, + raw_deliveries: Arc, + projection_deliveries: Arc, + epoch: ClientBindingEpoch, } impl NativeFlow { - async fn connect( - trusted_relay_pubkey: PublicKey, - expected_author_pubkey: PublicKey, - epoch: ClientBindingEpoch, - ) -> Self { + async fn connect(relay_keys: &Keys, author_keys: &Keys) -> Self { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind synthetic relay to an OS-assigned loopback port"); @@ -119,28 +188,130 @@ impl NativeFlow { assert!(address.ip().is_loopback()); assert_ne!(address.port(), 0); + let relay_pubkey = relay_keys.public_key(); + let challenge = Uuid::new_v4().to_string(); + let server_challenge = challenge.clone(); let relay = tokio::spawn(async move { - let (stream, peer) = listener.accept().await.expect("accept native client"); + let (mut nip11, peer) = listener.accept().await.expect("accept NIP-11 client"); + assert!(peer.ip().is_loopback()); + let mut request = [0_u8; 4096]; + let read = nip11.read(&mut request).await.expect("read NIP-11 request"); + assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET / HTTP/1.1")); + let body = json!({ "self": relay_pubkey.to_hex() }).to_string(); + nip11 + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/nostr+json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), body + ) + .as_bytes(), + ) + .await + .expect("write NIP-11 identity"); + let (stream, peer) = listener.accept().await.expect("accept native WebSocket"); assert!(peer.ip().is_loopback()); - accept_async(stream) + let mut socket = accept_async(stream) .await - .expect("accept WebSocket upgrade") + .expect("accept WebSocket upgrade"); + socket + .send(Message::Text( + json!(["AUTH", server_challenge]).to_string().into(), + )) + .await + .expect("send NIP-42 challenge"); + socket + }); + let relay_url = format!("ws://{address}/"); + let state = app_state::AppState::synthetic(author_keys.clone(), relay_url.clone()); + let manager = WebSocketManager::default(); + let raw_deliveries = Arc::new(AtomicUsize::new(0)); + let raw_for_channel = raw_deliveries.clone(); + let on_message = Channel::new(move |_: InvokeResponseBody| { + raw_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) }); - let (client_socket, response) = connect_async(format!("ws://{address}")) + let projection_deliveries = Arc::new(AtomicUsize::new(0)); + let projection_for_channel = projection_deliveries.clone(); + let on_projection = Channel::new(move |_: InvokeResponseBody| { + projection_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let id = native_websocket::connect_status_for_test( + &manager, + &state, + relay_url.clone(), + on_message, + on_projection, + ) + .await + .expect("open production status-capable WebSocket"); + let mut relay_socket = relay.await.expect("join synthetic relay accept task"); + + let proof = tokio::time::timeout(RECEIVE_TIMEOUT, async { + loop { + if let Ok(proof) = manager + .status_auth_proof(id, &challenge, &relay_url, author_keys.public_key()) + .await + { + break proof; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("production socket records exact NIP-42 challenge"); + let epoch = proof.connection_epoch().clone(); + ClientBindingEpoch::parse(epoch.as_str()).expect("production epoch is canonical UUIDv4"); + let auth = EventBuilder::new(Kind::Custom(22242), "") + .tags([ + Tag::parse(["relay", relay_url.as_str()]).expect("relay tag"), + Tag::parse(["challenge", challenge.as_str()]).expect("challenge tag"), + Tag::parse([ + CLIENT_BINDING_SCOPE_TAG, + "1", + epoch.as_str(), + proof.relay_signer().to_hex().as_str(), + ]) + .expect("binding scope tag"), + ]) + .sign_with_keys(author_keys) + .expect("sign scoped NIP-42 AUTH"); + let scope = ClientBindingScopeV1::from_verified_auth_event(&auth) + .expect("relay accepts signed binding scope"); + assert_eq!(scope.connection_epoch(), &epoch); + assert_eq!(scope.relay_signer(), relay_keys.public_key()); + manager + .complete_status_auth(id, &proof) + .await + .expect("production manager activates authenticated projection owner"); + native_websocket::send_message( + &manager, + id, + WebSocketMessage::Text(json!(["AUTH", auth]).to_string()), + ) + .await + .expect("send scoped AUTH through production native socket"); + let auth_frame = tokio::time::timeout(RECEIVE_TIMEOUT, relay_socket.next()) .await - .expect("connect native loopback WebSocket"); - assert_eq!(response.status(), 101); - let relay_socket = relay.await.expect("join synthetic relay accept task"); + .expect("relay receives AUTH") + .expect("AUTH frame exists") + .expect("AUTH frame valid"); + let Message::Text(auth_text) = auth_frame else { + panic!("scoped AUTH must be text") + }; + let auth_wire: serde_json::Value = serde_json::from_str(&auth_text).expect("AUTH JSON"); + let verified = Event::from_json(auth_wire[1].to_string()).expect("AUTH event parses"); + assert!(verified.verify_id() && verified.verify_signature()); + ClientBindingScopeV1::from_verified_auth_event(&verified) + .expect("relay revalidates signed scope"); Self { relay_socket, - client_socket, - session: ClientBindingStatusSession::new( - trusted_relay_pubkey, - expected_author_pubkey, - epoch, - ), - projection: None, + manager, + id, + raw_deliveries, + projection_deliveries, + epoch, } } @@ -152,46 +323,50 @@ impl NativeFlow { ), "reserved helper requires an exact native-owned subscription id" ); - let consumed = self.send_event(sub_id, event, now).await; - assert!(consumed, "production session must swallow reserved frames"); + let _ = now; + self.send_event(sub_id, event, true).await; } async fn send_ordinary_event(&mut self, event: &Event, now: u64) { - let consumed = self.send_event(ORDINARY_SUB_ID, event, now).await; - assert!( - !consumed, - "ordinary events must remain outside the status fold" - ); + let _ = now; + self.send_event(ORDINARY_SUB_ID, event, false).await; } - async fn send_event(&mut self, sub_id: &str, event: &Event, now: u64) -> bool { + async fn send_event(&mut self, sub_id: &str, event: &Event, reserved: bool) { + let raw_before = self.raw_deliveries.load(Ordering::SeqCst); let event_json = serde_json::to_value(event).expect("serialize synthetic Nostr event"); let frame = json!(["EVENT", sub_id, event_json]).to_string(); self.relay_socket .send(Message::Text(frame.into())) .await .expect("send synthetic relay frame"); - - let message = tokio::time::timeout(RECEIVE_TIMEOUT, self.client_socket.next()) + self.relay_socket + .send(Message::Text("projection-fold-barrier".into())) .await - .expect("native socket receives relay frame before timeout") - .expect("native socket remains connected") - .expect("native socket receives a valid WebSocket message"); - let text = match message { - Message::Text(text) => text.to_string(), - other => panic!("expected relay text frame, received {other:?}"), - }; - let update = self.session.consume_text(&text, now); - let consumed = update.is_some(); - if let Some(update) = update { - self.apply(update); - } - consumed + .expect("send ordered fold barrier"); + let expected = raw_before + if reserved { 1 } else { 2 }; + tokio::time::timeout(RECEIVE_TIMEOUT, async { + while self.raw_deliveries.load(Ordering::SeqCst) < expected { + tokio::task::yield_now().await; + } + }) + .await + .expect("production raw channel reaches ordered barrier"); + assert_eq!( + self.raw_deliveries.load(Ordering::SeqCst), + expected, + "only exact reserved text frames are swallowed" + ); } - fn expire(&mut self, now: u64) { - let update = self.session.expire(now); - self.apply(update); + async fn wait_for_expiry(&self) { + tokio::time::timeout(Duration::from_secs(5), async { + while self.projection().await.is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("production monotonic expiry clears projection"); } async fn physical_disconnect(&mut self) { @@ -199,30 +374,32 @@ impl NativeFlow { .send(Message::Close(None)) .await .expect("relay closes physical WebSocket"); - let message = tokio::time::timeout(RECEIVE_TIMEOUT, self.client_socket.next()) - .await - .expect("native socket observes physical disconnect before timeout"); + tokio::time::timeout(RECEIVE_TIMEOUT, async { + while native_websocket::connection_present_for_test(&self.manager, self.id).await { + tokio::task::yield_now().await; + } + }) + .await + .expect("production manager observes physical disconnect"); + assert!(self.projection().await.is_none()); + } + + async fn projection(&self) -> Option { assert!( - matches!(message, Some(Ok(Message::Close(_))) | None), - "native transport must observe a close frame or EOF" + self.projection_deliveries.load(Ordering::SeqCst) > 0, + "authenticated production projection channel is active" ); - let update = self.session.disconnect(); - self.apply(update); + native_websocket::current_projection_for_test(&self.manager).await } - fn apply(&mut self, update: ProjectionUpdate) { - match update { - ProjectionUpdate::Unchanged => {} - ProjectionUpdate::Clear => self.projection = None, - ProjectionUpdate::Current(projection) => self.projection = Some(projection), - } + async fn logout(&self) { + self.manager.suspend_projection().await; + assert!(self.projection().await.is_none()); } } fn random_epoch() -> ClientBindingEpoch { - let mut bytes = [0_u8; 32]; - getrandom::getrandom(&mut bytes).expect("generate synthetic connection epoch"); - ClientBindingEpoch::from_random_bytes(bytes) + ClientBindingEpoch::new_v4() } fn random_domain() -> CommunityId { @@ -292,24 +469,24 @@ fn raw_status_event(relay: &Keys, content: &str, issued_at: u64) -> Event { async fn established_flow( relay: &Keys, - author: PublicKey, + author: &Keys, domain: CommunityId, now: u64, ) -> NativeFlow { - let epoch = random_epoch(); - let mut flow = NativeFlow::connect(relay.public_key(), author, epoch.clone()).await; - let bootstrap = bootstrap_event(relay, domain, author, epoch, now); + let mut flow = NativeFlow::connect(relay, author).await; + let bootstrap = bootstrap_event(relay, domain, author.public_key(), flow.epoch.clone(), now); flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, now) .await; - let current = current_event(relay, domain, author, 1, now, now + 120); + let current = current_event(relay, domain, author.public_key(), 1, now, now + 120); flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) .await; - assert!(flow.projection.is_some()); + assert!(flow.projection().await.is_some()); flow } #[tokio::test] async fn loopback_relay_drives_production_projection_and_trace() { + let now = native_websocket::unix_now_for_test(); let relay = Keys::generate(); let wrong_signer = Keys::generate(); let author = Keys::generate(); @@ -323,210 +500,180 @@ async fn loopback_relay_drives_production_projection_and_trace() { // One physical connection exercises the revision fold as a sequence, proving that // duplicate delivery retains current state while trusted-invalid evidence clears it. - let epoch = random_epoch(); - let mut flow = - NativeFlow::connect(relay.public_key(), author.public_key(), epoch.clone()).await; - let bootstrap = bootstrap_event(&relay, domain, author.public_key(), epoch.clone(), NOW); - flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, NOW) + let mut flow = NativeFlow::connect(&relay, &author).await; + let epoch = flow.epoch.clone(); + let bootstrap = bootstrap_event(&relay, domain, author.public_key(), epoch.clone(), now); + flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, now) .await; - trace.record("bootstrap", &flow); + trace.record("bootstrap", &flow).await; - let current = current_event(&relay, domain, author.public_key(), 10, NOW, NOW + 120); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, NOW) + let current = current_event(&relay, domain, author.public_key(), 10, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) .await; - let first_projection = - serde_json::to_value(&flow.projection).expect("serialize first production projection"); - let projected = flow.projection.as_ref().expect("current status projects"); + let first_projection = serde_json::to_value(flow.projection().await) + .expect("serialize first production projection"); + let projected = flow.projection().await.expect("current status projects"); assert_eq!(projected.event_author_pubkey, author.public_key().to_hex()); - assert_eq!(projected.fresh_until, NOW + 120); + assert_eq!(projected.fresh_until, now + 120); assert_eq!(projected.connection_epoch, epoch.as_str()); - trace.record("current", &flow); + trace.record("current", &flow).await; - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, NOW) + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) .await; assert_eq!( - serde_json::to_value(&flow.projection).expect("serialize duplicate projection"), + serde_json::to_value(flow.projection().await).expect("serialize duplicate projection"), first_projection ); - trace.record("duplicate", &flow); + trace.record("duplicate", &flow).await; - let equal_conflict = current_event(&relay, domain, author.public_key(), 10, NOW, NOW + 121); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &equal_conflict, NOW) + let equal_conflict = current_event(&relay, domain, author.public_key(), 10, now, now + 121); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &equal_conflict, now) .await; - assert!(flow.projection.is_none()); - trace.record("equal-conflict", &flow); + assert!(flow.projection().await.is_none()); + trace.record("equal-conflict", &flow).await; - let rollback = current_event(&relay, domain, author.public_key(), 9, NOW, NOW + 120); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &rollback, NOW) + let rollback = current_event(&relay, domain, author.public_key(), 9, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &rollback, now) .await; - assert!(flow.projection.is_none()); - trace.record("rollback", &flow); + assert!(flow.projection().await.is_none()); + trace.record("rollback", &flow).await; - let newer = current_event(&relay, domain, author.public_key(), 11, NOW, NOW + 120); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &newer, NOW) + let newer = current_event(&relay, domain, author.public_key(), 11, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &newer, now) .await; - assert!(flow.projection.is_some()); - trace.record("newer-restoration", &flow); + assert!(flow.projection().await.is_some()); + trace.record("newer-restoration", &flow).await; - let withdrawal = withdrawal_event(&relay, domain, author.public_key(), 12, NOW, NOW + 120); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &withdrawal, NOW) + let withdrawal = withdrawal_event(&relay, domain, author.public_key(), 12, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &withdrawal, now) .await; - assert!(flow.projection.is_none()); - trace.record("withdrawal", &flow); + assert!(flow.projection().await.is_none()); + trace.record("withdrawal", &flow).await; - let short_current = current_event(&relay, domain, author.public_key(), 13, NOW, NOW + 2); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &short_current, NOW) + let short_now = native_websocket::unix_now_for_test(); + let short_current = current_event( + &relay, + domain, + author.public_key(), + 13, + short_now, + short_now + 2, + ); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &short_current, short_now) .await; - assert!(flow.projection.is_some()); - flow.expire(NOW + 2); - assert!(flow.projection.is_none()); - trace.record("passive-expiry", &flow); - - let disconnect_current = - current_event(&relay, domain, author.public_key(), 14, NOW + 3, NOW + 123); - flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &disconnect_current, NOW + 3) + flow.wait_for_expiry().await; + trace.record("passive-expiry", &flow).await; + + let disconnect_current = current_event(&relay, domain, author.public_key(), 14, now, now + 123); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &disconnect_current, now) .await; - assert!(flow.projection.is_some()); + assert!(flow.projection().await.is_some()); flow.physical_disconnect().await; - assert!(flow.projection.is_none()); - trace.record("disconnect", &flow); + trace.record("disconnect", &flow).await; - let mut reconnected = established_flow(&relay, author.public_key(), domain, NOW + 10).await; - trace.record("reconnect", &reconnected); - reconnected.physical_disconnect().await; - trace.record("logout", &reconnected); + let reconnected = established_flow(&relay, &author, domain, now).await; + trace.record("reconnect", &reconnected).await; + reconnected.logout().await; + trace.record("logout", &reconnected).await; - let mut restarted = established_flow(&relay, author.public_key(), domain, NOW + 20).await; + let mut restarted = established_flow(&relay, &author, domain, now).await; restarted.physical_disconnect().await; - trace.record("restart", &restarted); + trace.record("restart", &restarted).await; // A different physical relay connection starts empty even when the signer is reused. - let relay_epoch = random_epoch(); - let relay_scope = - NativeFlow::connect(relay.public_key(), author.public_key(), relay_epoch).await; - trace.record("relay-scope-change", &relay_scope); + let relay_scope = NativeFlow::connect(&relay, &author).await; + trace.record("relay-scope-change", &relay_scope).await; // Wrong-signer traffic is untrusted noise and cannot create or clear presentation. - let signer_epoch = random_epoch(); - let mut signer_scope = NativeFlow::connect( - wrong_signer.public_key(), + let mut signer_scope = NativeFlow::connect(&wrong_signer, &author).await; + let old_signer_bootstrap = bootstrap_event( + &relay, + domain, author.public_key(), - signer_epoch.clone(), - ) - .await; - let old_signer_bootstrap = - bootstrap_event(&relay, domain, author.public_key(), signer_epoch, NOW + 30); + signer_scope.epoch.clone(), + now, + ); signer_scope - .send_reserved_event( - CLIENT_BINDING_BOOTSTRAP_SUB_ID, - &old_signer_bootstrap, - NOW + 30, - ) + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &old_signer_bootstrap, now) .await; - trace.record("signer-scope-change", &signer_scope); + trace.record("signer-scope-change", &signer_scope).await; - let author_epoch = random_epoch(); - let mut author_scope = NativeFlow::connect( - relay.public_key(), - other_author.public_key(), - author_epoch.clone(), - ) - .await; - let old_author_bootstrap = - bootstrap_event(&relay, domain, author.public_key(), author_epoch, NOW + 31); + let mut author_scope = NativeFlow::connect(&relay, &other_author).await; + let old_author_bootstrap = bootstrap_event( + &relay, + domain, + author.public_key(), + author_scope.epoch.clone(), + now, + ); author_scope - .send_reserved_event( - CLIENT_BINDING_BOOTSTRAP_SUB_ID, - &old_author_bootstrap, - NOW + 31, - ) + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &old_author_bootstrap, now) .await; - trace.record("author-scope-change", &author_scope); + trace.record("author-scope-change", &author_scope).await; - let domain_epoch = random_epoch(); - let mut domain_scope = NativeFlow::connect( - relay.public_key(), - author.public_key(), - domain_epoch.clone(), - ) - .await; + let mut domain_scope = NativeFlow::connect(&relay, &author).await; let domain_bootstrap = bootstrap_event( &relay, other_domain, author.public_key(), - domain_epoch, - NOW + 32, + domain_scope.epoch.clone(), + now, ); domain_scope - .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &domain_bootstrap, NOW + 32) + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &domain_bootstrap, now) .await; - let old_domain_status = - current_event(&relay, domain, author.public_key(), 1, NOW + 32, NOW + 152); + let old_domain_status = current_event(&relay, domain, author.public_key(), 1, now, now + 152); domain_scope - .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &old_domain_status, NOW + 32) + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &old_domain_status, now) .await; - trace.record("domain-scope-change", &domain_scope); + trace.record("domain-scope-change", &domain_scope).await; let old_epoch = random_epoch(); - let new_epoch = random_epoch(); - assert_ne!(old_epoch, new_epoch); - let mut epoch_scope = - NativeFlow::connect(relay.public_key(), author.public_key(), new_epoch).await; + let mut epoch_scope = NativeFlow::connect(&relay, &author).await; + assert_ne!(old_epoch, epoch_scope.epoch); let stale_epoch_bootstrap = - bootstrap_event(&relay, domain, author.public_key(), old_epoch, NOW + 33); + bootstrap_event(&relay, domain, author.public_key(), old_epoch, now); epoch_scope - .send_reserved_event( - CLIENT_BINDING_BOOTSTRAP_SUB_ID, - &stale_epoch_bootstrap, - NOW + 33, - ) + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &stale_epoch_bootstrap, now) .await; - trace.record("epoch-scope-change", &epoch_scope); + trace.record("epoch-scope-change", &epoch_scope).await; - let mut malformed = established_flow(&relay, author.public_key(), domain, NOW + 40).await; - let malformed_status = raw_status_event(&relay, r#"{"version":1,"domain":"broken"}"#, NOW + 40); + let mut malformed = established_flow(&relay, &author, domain, now).await; + let malformed_status = raw_status_event(&relay, r#"{"version":1,"domain":"broken"}"#, now); malformed - .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &malformed_status, NOW + 40) + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &malformed_status, now) .await; - trace.record("malformed-trusted", &malformed); + trace.record("malformed-trusted", &malformed).await; - let mut unsupported = established_flow(&relay, author.public_key(), domain, NOW + 41).await; - let unsupported_status = raw_status_event(&relay, r#"{"version":2}"#, NOW + 41); + let mut unsupported = established_flow(&relay, &author, domain, now).await; + let unsupported_status = raw_status_event(&relay, r#"{"version":2}"#, now); unsupported - .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &unsupported_status, NOW + 41) + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &unsupported_status, now) .await; - trace.record("unsupported-version", &unsupported); + trace.record("unsupported-version", &unsupported).await; - let mut mismatched_author = - established_flow(&relay, author.public_key(), domain, NOW + 42).await; - let author_mismatch = current_event( - &relay, - domain, - other_author.public_key(), - 2, - NOW + 42, - NOW + 162, - ); + let mut mismatched_author = established_flow(&relay, &author, domain, now).await; + let author_mismatch = + current_event(&relay, domain, other_author.public_key(), 2, now, now + 162); mismatched_author - .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &author_mismatch, NOW + 42) + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &author_mismatch, now) .await; - trace.record("author-mismatch", &mismatched_author); + trace.record("author-mismatch", &mismatched_author).await; // Ordinary kind-0 and NIP-85 traffic crosses the same socket but never enters the // reserved production fold, so neither can manufacture a current projection. - let mut legacy = - NativeFlow::connect(relay.public_key(), author.public_key(), random_epoch()).await; + let mut legacy = NativeFlow::connect(&relay, &author).await; let spoofed_profile = EventBuilder::new( Kind::Metadata, r#"{"display_name":"Spoofed Verified User","nip05":"spoof@identity.example.invalid"}"#, ) .sign_with_keys(&profile_spoofer) .expect("sign synthetic profile spoof"); - legacy.send_ordinary_event(&spoofed_profile, NOW + 50).await; - trace.record("profile-spoof", &legacy); + legacy.send_ordinary_event(&spoofed_profile, now + 50).await; + trace.record("profile-spoof", &legacy).await; let subject = author.public_key().to_hex(); - let expiry = (NOW + 170).to_string(); + let expiry = (now + 170).to_string(); let nip85 = EventBuilder::new( Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), String::new(), @@ -542,8 +689,8 @@ async fn loopback_relay_drives_production_projection_and_trace() { ]) .sign_with_keys(&relay) .expect("sign synthetic NIP-85 assertion"); - legacy.send_ordinary_event(&nip85, NOW + 50).await; - trace.record("nip85-no-fallback", &legacy); + legacy.send_ordinary_event(&nip85, now + 50).await; + trace.record("nip85-no-fallback", &legacy).await; let cases = trace.steps.iter().map(|step| step.case).collect::>(); assert_eq!( From 5cdcb88a50fd08c0231e0cd0dc9c75622ff218a3 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:05:15 -0500 Subject: [PATCH 34/40] test(desktop): drive status trace through native channel Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- ...urrent-binding-status-native-trace.spec.ts | 24 +--- .../e2e/j3c/currentBindingStatusTrace.ts | 133 ++++++++++++++++-- 2 files changed, 124 insertions(+), 33 deletions(-) diff --git a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts index 2f7dd1a767..4213252749 100644 --- a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts +++ b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts @@ -3,9 +3,11 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../../helpers/bridge"; import { forwardTraceStep, + installNativeProjectionTraceAdapter, loadCurrentBindingStatusTrace, traceStep, type NativeCurrentProjection, + waitForNativeProjectionTraceAdapter, } from "./currentBindingStatusTrace"; const trace = loadCurrentBindingStatusTrace(); @@ -25,25 +27,6 @@ async function waitForMockLiveSubscription(page: Page) { .toBe(true); } -async function waitForProjectionBridgeBootstrap(page: Page) { - await expect - .poll(() => - page.evaluate(() => - window.__BUZZ_E2E_COMMANDS__?.includes( - "get_current_binding_projection", - ), - ), - ) - .toBe(true); - - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }), - ); -} - function otherSyntheticAuthor(projectedAuthors: ReadonlySet): string { for (const identity of [TEST_IDENTITIES.bob, TEST_IDENTITIES.charlie]) { if (!projectedAuthors.has(identity.pubkey)) return identity.pubkey; @@ -145,11 +128,12 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async nip05Handle: `${LEGACY_ALIAS_MARKER}-${index}@example.invalid`, })), }); + await installNativeProjectionTraceAdapter(page); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page); - await waitForProjectionBridgeBootstrap(page); + await waitForNativeProjectionTraceAdapter(page); const rows = new Map(); let createdAt = clockStartSeconds - projectedAuthors.size - 1; diff --git a/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts b/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts index 9828370318..f20afa0ea9 100644 --- a/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts +++ b/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts @@ -3,11 +3,12 @@ import { isAbsolute } from "node:path"; import type { Page } from "@playwright/test"; -import { CURRENT_PROJECTION_EVENT } from "../../../src/features/binding-status/CurrentProjectionBridge"; import type { CurrentProjection } from "../../../src/features/binding-status/currentProjectionStore"; const TRACE_ENV = "BUZZ_J3C_PROJECTION_TRACE"; const LOWERCASE_HEX_256 = /^[0-9a-f]{64}$/; +const CANONICAL_UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; export const CURRENT_BINDING_TRACE_CASES = [ "bootstrap", @@ -84,7 +85,7 @@ function isCurrentProjection(value: unknown): value is NativeCurrentProjection { Number.isSafeInteger(value.freshUntil) && value.freshUntil > 0 && typeof value.connectionEpoch === "string" && - LOWERCASE_HEX_256.test(value.connectionEpoch) + CANONICAL_UUID_V4.test(value.connectionEpoch) ); } @@ -160,19 +161,125 @@ export function traceStep( return step; } +export async function installNativeProjectionTraceAdapter( + page: Page, +): Promise { + await page.addInitScript(() => { + type Invoke = ( + command: string, + args: Record, + options: unknown, + ) => unknown; + type ProjectionChannel = { + onmessage: (projection: unknown) => void; + }; + type TraceAdapterWindow = typeof window & { + __TAURI_INTERNALS__?: Record; + __BUZZ_J3C_FORWARD_NATIVE_PROJECTION__?: (projection: unknown) => void; + __BUZZ_J3C_STATUS_AUTH_BOUND__?: () => boolean; + }; + + const tauriWindow = window as TraceAdapterWindow; + const internals = tauriWindow.__TAURI_INTERNALS__ ?? {}; + let sharedInvoke = + typeof internals.invoke === "function" + ? (internals.invoke as Invoke) + : null; + let projectionChannel: ProjectionChannel | null = null; + let statusSocketId: number | null = null; + let authBound = false; + + const invoke: Invoke = (command, args, options) => { + if (!sharedInvoke) { + throw new Error(`Shared mock bridge is not installed for ${command}.`); + } + + if (command === "plugin:websocket|connect_with_status") { + const { onProjection, ...ordinaryArgs } = args as { + onProjection?: ProjectionChannel; + } & Record; + if (typeof onProjection?.onmessage !== "function") { + throw new Error( + "Status connection omitted its native projection Channel.", + ); + } + + authBound = false; + projectionChannel = onProjection; + statusSocketId = null; + return Promise.resolve( + sharedInvoke("plugin:websocket|connect", ordinaryArgs, options), + ).then((id) => { + if (typeof id !== "number" || !Number.isSafeInteger(id)) { + throw new Error( + "Shared mock bridge returned an invalid socket ID.", + ); + } + statusSocketId = id; + return id; + }); + } + + if (command === "create_auth_event") { + if ( + statusSocketId === null || + args.nativeWebsocketId !== statusSocketId + ) { + throw new Error( + "Auth event is not bound to the current native status socket ID.", + ); + } + authBound = true; + } + + return sharedInvoke(command, args, options); + }; + + Object.defineProperty(internals, "invoke", { + configurable: true, + get: () => invoke, + set: (nextInvoke: Invoke) => { + sharedInvoke = nextInvoke; + }, + }); + tauriWindow.__TAURI_INTERNALS__ = internals; + tauriWindow.__BUZZ_J3C_STATUS_AUTH_BOUND__ = () => authBound; + tauriWindow.__BUZZ_J3C_FORWARD_NATIVE_PROJECTION__ = (projection) => { + if (!authBound || statusSocketId === null || !projectionChannel) { + throw new Error( + "Native status projection Channel is not authenticated.", + ); + } + projectionChannel.onmessage(projection); + }; + }); +} + +export async function waitForNativeProjectionTraceAdapter( + page: Page, +): Promise { + await page.waitForFunction( + () => + ( + window as typeof window & { + __BUZZ_J3C_STATUS_AUTH_BOUND__?: () => boolean; + } + ).__BUZZ_J3C_STATUS_AUTH_BOUND__?.() === true, + ); +} + export async function forwardTraceStep( page: Page, step: CurrentBindingTraceStep, ): Promise { - await page.evaluate( - async ({ eventName, projection }) => { - const emit = window.__BUZZ_E2E_EMIT_TAURI_EVENT__; - if (!emit) throw new Error("Mock Tauri event bridge is not installed."); - await emit(eventName, projection); - }, - { - eventName: CURRENT_PROJECTION_EVENT, - projection: step.projection, - }, - ); + await page.evaluate((projection) => { + const forward = ( + window as typeof window & { + __BUZZ_J3C_FORWARD_NATIVE_PROJECTION__?: (projection: unknown) => void; + } + ).__BUZZ_J3C_FORWARD_NATIVE_PROJECTION__; + if (!forward) + throw new Error("Native projection adapter is not installed."); + forward(projection); + }, step.projection); } From 4d02063a5f2b0849a522e43f145d3b9bdf38a271 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:20:44 -0500 Subject: [PATCH 35/40] test(desktop): allow status trace setup headroom Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../tests/e2e/j3c/current-binding-status-native-trace.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts index 4213252749..cae86c56dc 100644 --- a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts +++ b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts @@ -13,6 +13,7 @@ import { const trace = loadCurrentBindingStatusTrace(); const LEGACY_VERIFIED_NAME_MARKER = "legacy-verified-name-must-not-authorize"; const LEGACY_ALIAS_MARKER = "legacy-relay-alias-must-not-authorize"; +const PROJECTION_SETUP_HEADROOM_SECONDS = 60; async function waitForMockLiveSubscription(page: Page) { await expect @@ -113,7 +114,8 @@ test("Rust native-flow trace drives exact-author lifecycle presentation", async if (activationProjection === null) { throw new Error("Native current trace step must contain a projection."); } - const clockStartSeconds = expiryProjection.freshUntil - 2; + const clockStartSeconds = + expiryProjection.freshUntil - PROJECTION_SETUP_HEADROOM_SECONDS; if (clockStartSeconds <= 0) { throw new Error( "Native trace freshUntil is too small for expiry coverage.", From b760e7c3bbecb2288654480cd2c03eaac8d2a506 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:24:11 -0500 Subject: [PATCH 36/40] test(desktop): require native trace browser flow Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/playwright.config.ts | 1 - desktop/playwright.j3c.config.ts | 29 ++++ .../tests/e2e/current-binding-status.spec.ts | 152 ------------------ scripts/test-j3c-current-binding-status.sh | 32 ++++ 4 files changed, 61 insertions(+), 153 deletions(-) create mode 100644 desktop/playwright.j3c.config.ts delete mode 100644 desktop/tests/e2e/current-binding-status.spec.ts create mode 100755 scripts/test-j3c-current-binding-status.sh diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index fd9fd4f93e..231691118f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -34,7 +34,6 @@ export default defineConfig({ "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", - "**/current-binding-status.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", diff --git a/desktop/playwright.j3c.config.ts b/desktop/playwright.j3c.config.ts new file mode 100644 index 0000000000..66f4713c42 --- /dev/null +++ b/desktop/playwright.j3c.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e/j3c", + testMatch: "current-binding-status-native-trace.spec.ts", + timeout: 30_000, + retries: 0, + workers: 1, + outputDir: "test-results/j3c-current-binding-status", + reporter: [["list"]], + use: { + ...devices["Desktop Chrome"], + baseURL: "http://127.0.0.1:4175", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "current-binding-status-native-trace", + }, + ], + webServer: { + command: "python3 -m http.server 4175 -d dist", + cwd: ".", + reuseExistingServer: false, + url: "http://127.0.0.1:4175", + }, +}); diff --git a/desktop/tests/e2e/current-binding-status.spec.ts b/desktop/tests/e2e/current-binding-status.spec.ts deleted file mode 100644 index e68fb0d519..0000000000 --- a/desktop/tests/e2e/current-binding-status.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; - -const MATCHING_MESSAGE = "Current binding belongs to this exact author."; -const OTHER_MESSAGE = "Current binding must not decorate this author."; -const LEGACY_ALIAS = "legacy-relay-alias-must-stay-hidden"; -const CONNECTION_EPOCH = "11111111-1111-4111-8111-111111111111"; - -async function waitForMockLiveSubscription(page: Page) { - await expect - .poll(() => - page.evaluate( - () => - window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ - channelName: "general", - }) ?? false, - ), - ) - .toBe(true); -} - -async function waitForProjectionBridgeBootstrap(page: Page) { - await expect - .poll(() => - page.evaluate( - () => window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__?.(null) ?? false, - ), - ) - .toBe(true); -} - -async function emitProjection(page: Page, payload: unknown) { - const emitted = await page.evaluate((value) => { - return window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__?.(value) ?? false; - }, payload); - if (!emitted) throw new Error("Native projection channel is not connected."); -} - -test("current relay binding is exact-author, generic, clearable, and passively expiring", async ({ - page, -}) => { - await installMockBridge(page); - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await waitForMockLiveSubscription(page); - await waitForProjectionBridgeBootstrap(page); - - const createdAt = Math.floor(Date.now() / 1_000); - await page.evaluate( - ({ matchingMessage, otherMessage, matchingPubkey, otherPubkey, time }) => { - const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; - if (!emit) throw new Error("Mock live-message bridge is not installed."); - - emit({ - channelName: "general", - content: otherMessage, - createdAt: time, - pubkey: otherPubkey, - }); - emit({ - channelName: "general", - content: matchingMessage, - createdAt: time + 1, - pubkey: matchingPubkey, - }); - }, - { - matchingMessage: MATCHING_MESSAGE, - matchingPubkey: TEST_IDENTITIES.bob.pubkey, - otherMessage: OTHER_MESSAGE, - otherPubkey: TEST_IDENTITIES.charlie.pubkey, - time: createdAt, - }, - ); - - const matchingRow = page - .getByTestId("message-row") - .filter({ hasText: MATCHING_MESSAGE }); - const otherRow = page - .getByTestId("message-row") - .filter({ hasText: OTHER_MESSAGE }); - await expect(matchingRow).toBeVisible(); - await expect(otherRow).toBeVisible(); - await expect(matchingRow.getByTestId("message-author")).toBeVisible(); - await expect(otherRow.getByTestId("message-author")).toBeVisible(); - - const freshUntil = Math.floor(Date.now() / 1_000) + 30; - await emitProjection(page, { - connectionEpoch: CONNECTION_EPOCH, - eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, - freshUntil, - }); - const badge = matchingRow.getByTestId("current-relay-binding"); - await expect(badge).toHaveCount(1); - await expect(badge).toHaveAccessibleName("Current relay binding"); - await expect(otherRow.getByTestId("current-relay-binding")).toHaveCount(0); - await expect(page.getByTestId("current-relay-binding")).toHaveCount(1); - - const badgeMarkup = ( - await badge.evaluate((element) => element.outerHTML) - ).toLowerCase(); - for (const hiddenValue of [ - TEST_IDENTITIES.bob.pubkey, - String(freshUntil), - CONNECTION_EPOCH, - LEGACY_ALIAS, - "eventauthorpubkey", - "freshuntil", - "connectionepoch", - "verifiedname", - ]) { - expect(badgeMarkup).not.toContain(hiddenValue.toLowerCase()); - } - await expect( - matchingRow.getByRole("img", { exact: true, name: LEGACY_ALIAS }), - ).toHaveCount(0); - - for (const row of [matchingRow, otherRow]) { - await expect(row.getByTestId("relay-verified-identity")).toHaveCount(0); - await expect( - row.locator('[aria-label^="Relay-verified identity"]'), - ).toHaveCount(0); - await expect(row).not.toContainText("Relay-verified identity"); - await expect(row).not.toContainText("Verified as"); - await expect(row).not.toContainText(LEGACY_ALIAS); - } - - await emitProjection(page, null); - await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); - - const expiringFreshUntil = Math.floor(Date.now() / 1_000) + 4; - await emitProjection(page, { - connectionEpoch: "22222222-2222-4222-8222-222222222222", - eventAuthorPubkey: TEST_IDENTITIES.bob.pubkey, - freshUntil: expiringFreshUntil, - }); - await expect(badge).toBeVisible(); - expect(await page.evaluate(() => Date.now() / 1_000)).toBeLessThan( - expiringFreshUntil, - ); - - // No further event, navigation, or message update: the store's timer must - // clear the projection when the exclusive freshUntil boundary is reached. - await expect(page.getByTestId("current-relay-binding")).toHaveCount(0, { - timeout: 7_000, - }); - expect(await page.evaluate(() => Date.now() / 1_000)).toBeGreaterThanOrEqual( - expiringFreshUntil, - ); -}); diff --git a/scripts/test-j3c-current-binding-status.sh b/scripts/test-j3c-current-binding-status.sh new file mode 100755 index 0000000000..bd1d3275fe --- /dev/null +++ b/scripts/test-j3c-current-binding-status.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +artifact_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-j3c-binding-status.XXXXXX")" +trace_path="$artifact_dir/current-binding-status-projection.json" + +cd "$repo_root" + +TAURI_CONFIG='{"bundle":{"externalBin":[]}}' \ +BUZZ_J3C_PROJECTION_TRACE_OUT="$trace_path" \ +CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" \ +RUST_TEST_THREADS="${RUST_TEST_THREADS:-2}" \ + bin/cargo test \ + --manifest-path desktop/src-tauri/Cargo.toml \ + --test current_binding_status_native_flow \ + --locked \ + loopback_relay_drives_production_projection_and_trace \ + -- \ + --exact \ + --nocapture + +test -s "$trace_path" + +cd "$repo_root/desktop" +NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}" \ + ../bin/pnpm build:e2e +BUZZ_J3C_PROJECTION_TRACE="$trace_path" \ + ../bin/pnpm exec playwright test --config=playwright.j3c.config.ts + +printf 'J3C projection trace: %s\n' "$trace_path" From b5223d4366f08b9eedd364b08b476d3dfd832afb Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:14:16 -0500 Subject: [PATCH 37/40] refactor(desktop): split binding status integration Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src-tauri/src/native_websocket.rs | 969 +----------------- .../src-tauri/src/native_websocket_status.rs | 201 ++++ .../src-tauri/src/native_websocket_tests.rs | 770 ++++++++++++++ .../current_binding_status_native_flow.rs | 2 +- desktop/src/shared/api/relayClientSession.ts | 123 +-- .../shared/api/relayClientStatusConnection.ts | 89 ++ desktop/src/shared/api/tauri.ts | 1 - 7 files changed, 1102 insertions(+), 1053 deletions(-) create mode 100644 desktop/src-tauri/src/native_websocket_status.rs create mode 100644 desktop/src-tauri/src/native_websocket_tests.rs create mode 100644 desktop/src/shared/api/relayClientStatusConnection.ts diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 93cd5fadd0..159447642a 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,5 +1,4 @@ -use std::{collections::HashMap, net::IpAddr, sync::Arc, time::Duration}; - +use std::{collections::HashMap, sync::Arc, time::Duration}; use futures_util::{SinkExt, StreamExt}; use nostr::PublicKey; use serde::{Deserialize, Serialize}; @@ -13,24 +12,26 @@ use tokio_tungstenite::{ }, }; use tokio_util::sync::CancellationToken; -use url::{Host, Url}; use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; - use crate::{ app_state::AppState, client_binding_status_session::{ - is_reserved_text, ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, + is_reserved_text, ClientBindingStatusSession, ProjectionUpdate, }, }; - const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const WRITE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); const SEND_QUEUE_CAPACITY: usize = 64; -const NIP11_TIMEOUT: Duration = Duration::from_secs(5); -const MAX_NIP11_BODY_BYTES: usize = 64 * 1024; +#[path = "native_websocket_status.rs"] +mod native_websocket_status; +use native_websocket_status::{ + duration_until_unix_second, monotonic_deadline_after, prepare_status_session, + status_expiry_sleep, unix_now, PreparedStatus, ProjectionOwner, ProjectionState, StatusScope, +}; +pub(crate) use native_websocket_status::StatusAuthProof; pub(crate) fn install_crypto_provider() { // Dependencies enable both rustls providers; choose one before TLS setup. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -132,70 +133,12 @@ impl TestFoldPause { } } -struct StatusScope { - relay_url: String, - relay_signer: PublicKey, - expected_author: PublicKey, - epoch: ClientBindingEpoch, - projection_channel: Channel, - generation: u64, - attempt: u64, - challenge: Option, - auth_proven: bool, -} - -struct PreparedStatus { - session: ClientBindingStatusSession, - scope: StatusScope, -} - -pub(crate) struct StatusAuthProof { - handle: Arc, - challenge: String, - relay_url: String, - relay_signer: PublicKey, - expected_author: PublicKey, - epoch: ClientBindingEpoch, - generation: u64, - attempt: u64, -} - -impl StatusAuthProof { - pub(crate) fn connection_epoch(&self) -> &ClientBindingEpoch { - &self.epoch - } - - pub(crate) const fn relay_signer(&self) -> PublicKey { - self.relay_signer - } -} - -struct ProjectionOwner { - id: Id, - handle: Arc, - epoch: ClientBindingEpoch, - attempt: u64, - presentation_token: u64, - channel: Channel, -} - -#[derive(Default)] -struct ProjectionState { - generation: u64, - attempt_head: u64, - mutation_depth: u64, - suspended: bool, - owner: Option, - current: Option, -} - #[derive(Clone)] pub(crate) struct WebSocketManager { connections: Arc>>>, connect_cancel: Arc>, projection: Arc>, } - impl Default for WebSocketManager { fn default() -> Self { Self { @@ -808,106 +751,6 @@ async fn connect_internal( .await } -async fn prepare_status_session( - state: &AppState, - requested_url: &str, - projection_channel: Channel, - generation: u64, - attempt: u64, -) -> Option { - if requested_url != crate::relay::relay_ws_url_with_override(state) { - return None; - } - let expected_author = state.signing_keys().ok()?.public_key(); - let relay_signer = fetch_nip11_signer(requested_url).await.ok()?; - let epoch = ClientBindingEpoch::new_v4(); - Some(PreparedStatus { - session: ClientBindingStatusSession::new(relay_signer, expected_author, epoch.clone()), - scope: StatusScope { - relay_url: requested_url.to_owned(), - relay_signer, - expected_author, - epoch, - projection_channel, - generation, - attempt, - challenge: None, - auth_proven: false, - }, - }) -} - -#[derive(Deserialize)] -struct Nip11Identity { - #[serde(rename = "self")] - relay_self: String, -} - -async fn fetch_nip11_signer(relay_url: &str) -> Result { - let url = nip11_url(relay_url)?; - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(NIP11_TIMEOUT) - .build() - .map_err(|_| "NIP-11 unavailable".to_string())?; - let response = client - .get(url) - .header(reqwest::header::ACCEPT, "application/nostr+json") - .send() - .await - .map_err(|_| "NIP-11 unavailable".to_string())?; - if !response.status().is_success() - || response - .content_length() - .is_some_and(|length| length > MAX_NIP11_BODY_BYTES as u64) - { - return Err("NIP-11 unavailable".to_string()); - } - let mut body = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| "NIP-11 unavailable".to_string())?; - if body.len().saturating_add(chunk.len()) > MAX_NIP11_BODY_BYTES { - return Err("NIP-11 unavailable".to_string()); - } - body.extend_from_slice(&chunk); - } - let identity: Nip11Identity = - serde_json::from_slice(&body).map_err(|_| "NIP-11 unavailable".to_string())?; - if identity.relay_self.len() != 64 - || !identity - .relay_self - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err("NIP-11 unavailable".to_string()); - } - PublicKey::from_hex(&identity.relay_self).map_err(|_| "NIP-11 unavailable".to_string()) -} - -fn nip11_url(relay_url: &str) -> Result { - let mut url = Url::parse(relay_url).map_err(|_| "NIP-11 unavailable".to_string())?; - match url.scheme() { - "wss" => url - .set_scheme("https") - .map_err(|_| "NIP-11 unavailable".to_string())?, - "ws" if is_loopback_url(&url) => url - .set_scheme("http") - .map_err(|_| "NIP-11 unavailable".to_string())?, - _ => return Err("NIP-11 unavailable".to_string()), - } - Ok(url) -} - -fn is_loopback_url(url: &Url) -> bool { - match url.host() { - Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), - Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(), - Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(), - None => false, - } -} - pub(crate) async fn send_message( manager: &WebSocketManager, id: Id, @@ -1119,29 +962,6 @@ fn nip42_challenge(text: &str) -> Option { values.get(1)?.as_str().map(str::to_owned) } -fn unix_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()) -} - -fn duration_until_unix_second(unix_second: u64) -> Duration { - let Some(deadline) = std::time::UNIX_EPOCH.checked_add(Duration::from_secs(unix_second)) else { - return Duration::ZERO; - }; - deadline - .duration_since(std::time::SystemTime::now()) - .unwrap_or_default() -} - -fn monotonic_deadline_after(delay: Duration) -> tokio::time::Instant { - let now = tokio::time::Instant::now(); - now.checked_add(delay).unwrap_or(now) -} - -fn status_expiry_sleep(deadline: tokio::time::Instant) -> tokio::time::Sleep { - tokio::time::sleep_until(deadline) -} fn outbound_message(message: Message) -> OutboundMessage { match message { @@ -1175,772 +995,5 @@ pub fn init() -> TauriPlugin { } #[cfg(test)] -mod tests { - use super::*; - use futures_util::FutureExt; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - use buzz_core_pkg::client_binding_bootstrap::{ - CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, - }; - use tauri::ipc::InvokeResponseBody; - use tokio::io::duplex; - use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; - - fn silent_channel() -> Channel { - Channel::new(|_: InvokeResponseBody| Ok(())) - } - - #[tokio::test] - async fn secure_websocket_reaches_tls_without_panicking() { - install_crypto_provider(); - 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(); - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let result = std::panic::AssertUnwindSafe(tokio_tungstenite::connect_async(format!( - "wss://{address}" - ))) - .catch_unwind() - .await; - - assert!(result.is_ok(), "TLS setup must not panic"); - server.await.unwrap(); - } - - #[tokio::test] - async fn live_tcp_server_connect_send_and_disconnect() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let (received_tx, received_rx) = oneshot::channel(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); - let message = socket.next().await.unwrap().unwrap(); - received_tx.send(message).unwrap(); - while let Some(message) = socket.next().await { - if matches!(message, Ok(Message::Close(_))) { - break; - } - } - }); - - let manager = WebSocketManager::default(); - let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) - .await - .unwrap(); - send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) - .await - .unwrap(); - assert_eq!( - tokio::time::timeout(Duration::from_secs(1), received_rx) - .await - .unwrap() - .unwrap(), - Message::Text("live-probe".into()) - ); - - manager.disconnect(id).await; - assert!(!manager.connections.lock().await.contains_key(&id)); - tokio::time::timeout(Duration::from_secs(1), server) - .await - .expect("live server should observe native socket shutdown") - .unwrap(); - } - - #[tokio::test] - async fn eof_removes_connection() { - let manager = WebSocketManager::default(); - let (client_io, server_io) = duplex(1024); - let (client, server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(1, handle.clone()); - let task = tauri::async_runtime::spawn(run_connection( - 1, - client, - receiver, - handle.cancel.clone(), - silent_channel(), - manager.clone(), - )); - *handle.task.lock().await = Some(task); - - drop(server); - tokio::time::timeout(Duration::from_secs(1), async { - while manager.connections.lock().await.contains_key(&1) { - tokio::task::yield_now().await; - } - }) - .await - .expect("EOF should clean up its native connection ID"); - } - - #[tokio::test] - async fn disconnect_removes_and_drops_task_before_returning() { - struct DropGuard(Arc); - impl Drop for DropGuard { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - let manager = WebSocketManager::default(); - let dropped = Arc::new(AtomicBool::new(false)); - let task_dropped = dropped.clone(); - let (ready_tx, ready_rx) = oneshot::channel(); - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(Some(tauri::async_runtime::spawn(async move { - let _guard = DropGuard(task_dropped); - ready_tx.send(()).unwrap(); - std::future::pending::<()>().await; - }))), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(7, handle); - ready_rx.await.unwrap(); - - tokio::time::timeout(Duration::from_secs(1), manager.disconnect(7)) - .await - .expect("disconnect should abort an unresponsive task"); - assert!(!manager.connections.lock().await.contains_key(&7)); - assert!(dropped.load(Ordering::SeqCst)); - - // Repeated teardown is intentionally a no-op. - manager.disconnect(7).await; - } - - #[tokio::test] - async fn teardown_gate_stays_closed_until_tasks_stop() { - let manager = WebSocketManager::default(); - let gate = manager.connect_cancel.lock().await; - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(Some(tauri::async_runtime::spawn(async { - std::future::pending::<()>().await; - }))), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(1, handle); - gate.cancel(); - let handles = { - let mut connections = manager.connections.lock().await; - connections - .drain() - .map(|(_, handle)| handle) - .collect::>() - }; - - let shutdown = futures_util::future::join_all( - handles.into_iter().map(WebSocketManager::disconnect_handle), - ); - assert!(manager.connect_cancel.try_lock().is_err()); - shutdown.await; - drop(gate); - assert!(manager.connect_cancel.try_lock().is_ok()); - } - - #[tokio::test] - async fn disconnect_all_cancels_pending_connect_generation() { - let manager = WebSocketManager::default(); - let pending = manager.current_connect_cancel().await; - let generation = manager.projection_generation().await; - - manager.disconnect_all().await; - - assert!(pending.is_cancelled()); - assert!(!manager.current_connect_cancel().await.is_cancelled()); - assert_ne!(manager.projection_generation().await, generation); - } - - #[tokio::test] - async fn one_connection_does_not_block_another_send_queue() { - let manager = WebSocketManager::default(); - let (blocked_sender, blocked_receiver) = mpsc::channel(1); - blocked_sender - .send(SendRequest { - message: Message::Text("blocked".into()), - result: oneshot::channel().0, - }) - .await - .unwrap(); - let blocked = Arc::new(ConnectionHandle { - sender: blocked_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(1, blocked); - - let (healthy_sender, mut healthy_receiver) = mpsc::channel(1); - let healthy = Arc::new(ConnectionHandle { - sender: healthy_sender.clone(), - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(2, healthy); - - let (result, _) = oneshot::channel(); - tokio::time::timeout( - Duration::from_millis(50), - healthy_sender.send(SendRequest { - message: Message::Text("healthy".into()), - result, - }), - ) - .await - .expect("a full queue on one connection must not block another") - .unwrap(); - assert!(healthy_receiver.recv().await.is_some()); - drop(blocked_receiver); - } - - #[test] - fn nip11_url_accepts_tls_or_loopback_only() { - assert_eq!( - nip11_url("wss://relay.example.test/community?view=1") - .expect("secure relay URL is eligible") - .as_str(), - "https://relay.example.test/community?view=1" - ); - assert_eq!( - nip11_url("ws://localhost:3000/") - .expect("localhost relay URL is eligible") - .as_str(), - "http://localhost:3000/" - ); - assert!(nip11_url("ws://127.0.0.1:3000/").is_ok()); - assert!(nip11_url("ws://[::1]:3000/").is_ok()); - assert!(nip11_url("ws://relay.example.test/").is_err()); - assert!(nip11_url("http://localhost:3000/").is_err()); - - assert!(is_loopback_url( - &Url::parse("ws://LOCALHOST:3000/").expect("test URL") - )); - assert!(!is_loopback_url( - &Url::parse("ws://localhost.example.test/").expect("test URL") - )); - } - - fn test_epoch(suffix: u8) -> ClientBindingEpoch { - ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{suffix:012x}")) - .expect("synthetic epoch") - } - - fn test_handle(status_scope: Option) -> Arc { - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(status_scope), - fold_pause: None, - }) - } - - fn test_status_scope( - generation: u64, - attempt: u64, - relay: PublicKey, - author: PublicKey, - epoch: ClientBindingEpoch, - ) -> StatusScope { - StatusScope { - relay_url: "ws://localhost:3000/".to_string(), - relay_signer: relay, - expected_author: author, - epoch, - projection_channel: silent_channel(), - generation, - attempt, - challenge: None, - auth_proven: false, - } - } - - #[tokio::test] - async fn only_proven_status_socket_owns_projection_and_old_handle_is_fenced() { - let manager = WebSocketManager::default(); - let relay = nostr::Keys::generate().public_key(); - let author = nostr::Keys::generate().public_key(); - let (generation, old_attempt) = manager.begin_status_attempt().await.unwrap(); - let old_epoch = test_epoch(0x11); - let old = test_handle(Some(test_status_scope( - generation, - old_attempt, - relay, - author, - old_epoch.clone(), - ))); - manager.connections.lock().await.insert(7, old.clone()); - manager - .record_status_challenge(7, &old, "challenge-old") - .await; - let old_proof = manager - .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) - .await - .expect("exact native proof"); - - assert!(manager - .status_auth_proof(7, "challenge-old", "ws://wrong/", author) - .await - .is_err()); - - let (new_generation, new_attempt) = manager.begin_status_attempt().await.unwrap(); - assert_eq!(new_generation, generation); - assert!(new_attempt > old_attempt); - let new_epoch = test_epoch(0x22); - let new = test_handle(Some(test_status_scope( - new_generation, - new_attempt, - relay, - author, - new_epoch.clone(), - ))); - manager.connections.lock().await.insert(8, new.clone()); - manager - .record_status_challenge(8, &new, "challenge-new") - .await; - let new_proof = manager - .status_auth_proof(8, "challenge-new", "ws://localhost:3000/", author) - .await - .expect("replacement proof"); - manager - .complete_status_auth(8, &new_proof) - .await - .expect("replacement owns projection"); - - // An older eligible attempt cannot replace a newer owner even when its - // exact proof was captured before the newer attempt completed. - assert!(manager.complete_status_auth(7, &old_proof).await.is_err()); - - let current = CurrentProjection { - event_author_pubkey: author.to_hex(), - fresh_until: unix_now() + 60, - connection_epoch: new_epoch.as_str().to_owned(), - }; - manager - .apply_projection_update( - 8, - &new, - &new_epoch, - ProjectionUpdate::Current(current.clone()), - ) - .await; - assert_eq!( - manager.projection.lock().await.current, - Some(current.clone()) - ); - - manager.clear_projection_if_owner(7, &old, &old_epoch).await; - manager - .apply_projection_update( - 7, - &old, - &old_epoch, - ProjectionUpdate::Current(CurrentProjection { - event_author_pubkey: "11".repeat(32), - fresh_until: u64::MAX, - connection_epoch: old_epoch.as_str().to_owned(), - }), - ) - .await; - assert_eq!(manager.projection.lock().await.current, Some(current)); - - let read_only = test_handle(None); - manager - .connections - .lock() - .await - .insert(9, read_only.clone()); - assert!(manager - .status_auth_proof(9, "challenge", "ws://localhost:3000/", author) - .await - .is_err()); - - manager.invalidate_projection().await; - assert!(new.cancel.is_cancelled()); - assert!(manager.projection.lock().await.owner.is_none()); - assert!(manager.complete_status_auth(8, &new_proof).await.is_err()); - manager.suspend_projection().await; - assert!(manager.status_head().await.is_none()); - } - - #[tokio::test] - async fn overlapping_scope_mutations_keep_status_fail_closed() { - let manager = WebSocketManager::default(); - let relay = nostr::Keys::generate().public_key(); - let author = nostr::Keys::generate().public_key(); - let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); - let epoch = test_epoch(0x33); - let handle = test_handle(Some(test_status_scope( - generation, attempt, relay, author, epoch, - ))); - manager.connections.lock().await.insert(10, handle.clone()); - manager - .record_status_challenge(10, &handle, "challenge") - .await; - let proof = manager - .status_auth_proof(10, "challenge", "ws://localhost:3000/", author) - .await - .expect("pre-mutation proof"); - - manager.begin_scope_mutation().await; - assert!(manager.status_head().await.is_none()); - assert!(handle.cancel.is_cancelled()); - manager.begin_scope_mutation().await; - manager.finish_scope_mutation().await; - assert!(manager.status_head().await.is_none()); - - manager.finish_scope_mutation().await; - assert!(manager.status_head().await.is_some()); - assert!(manager.complete_status_auth(10, &proof).await.is_err()); - } - - #[tokio::test] - async fn security_token_exhaustion_fails_closed() { - let manager = WebSocketManager::default(); - manager.projection.lock().await.attempt_head = u64::MAX; - assert!(manager.begin_status_attempt().await.is_none()); - assert!(manager.projection.lock().await.suspended); - - let manager = WebSocketManager::default(); - let handle = test_handle(None); - let epoch = test_epoch(0x34); - let current = CurrentProjection { - event_author_pubkey: "11".repeat(32), - fresh_until: unix_now() + 60, - connection_epoch: epoch.as_str().to_owned(), - }; - { - let mut projection = manager.projection.lock().await; - projection.owner = Some(ProjectionOwner { - id: 12, - handle: handle.clone(), - epoch: epoch.clone(), - attempt: 1, - presentation_token: u64::MAX, - channel: silent_channel(), - }); - projection.current = Some(current.clone()); - } - manager - .apply_projection_update(12, &handle, &epoch, ProjectionUpdate::Current(current)) - .await; - let projection = manager.projection.lock().await; - assert!(projection.owner.is_none()); - assert!(projection.current.is_none()); - } - - #[tokio::test] - async fn matching_monotonic_expiry_clears_when_wall_clock_looks_early() { - let manager = WebSocketManager::default(); - let handle = test_handle(None); - let epoch = test_epoch(0x35); - let fresh_until = unix_now() + 60; - { - let mut projection = manager.projection.lock().await; - projection.owner = Some(ProjectionOwner { - id: 13, - handle: handle.clone(), - epoch: epoch.clone(), - attempt: 2, - presentation_token: 3, - channel: silent_channel(), - }); - projection.current = Some(CurrentProjection { - event_author_pubkey: "22".repeat(32), - fresh_until, - connection_epoch: epoch.as_str().to_owned(), - }); - } - - assert!(unix_now() < fresh_until, "test models a backward clock"); - manager - .expire_projection_if_owner(13, &handle, &epoch, 2, 3, fresh_until) - .await; - assert!(manager.projection.lock().await.current.is_none()); - } - - #[tokio::test] - async fn status_expiry_sleep_keeps_deadline_across_delayed_first_poll() { - let deadline = monotonic_deadline_after(Duration::from_millis(1)); - tokio::time::sleep(Duration::from_millis(10)).await; - - let sleep = status_expiry_sleep(deadline); - assert_eq!(sleep.deadline(), deadline); - sleep.await; - - let overflow_deadline = monotonic_deadline_after(Duration::MAX); - assert!(overflow_deadline <= tokio::time::Instant::now()); - } - - #[tokio::test] - async fn projection_expires_while_reserved_fold_is_blocked() { - let manager = WebSocketManager::default(); - let relay = nostr::Keys::generate().public_key(); - let author = nostr::Keys::generate().public_key(); - let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); - let epoch = test_epoch(0x44); - let pause = Arc::new(TestFoldPause::new()); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(Some(test_status_scope( - generation, - attempt, - relay, - author, - epoch.clone(), - ))), - fold_pause: Some(pause.clone()), - }); - manager.connections.lock().await.insert(11, handle.clone()); - manager - .record_status_challenge(11, &handle, "challenge") - .await; - let proof = manager - .status_auth_proof(11, "challenge", "ws://localhost:3000/", author) - .await - .expect("exact native proof"); - manager - .complete_status_auth(11, &proof) - .await - .expect("test socket owns projection"); - - let fresh_until = unix_now() + 2; - manager - .apply_projection_update( - 11, - &handle, - &epoch, - ProjectionUpdate::Current(CurrentProjection { - event_author_pubkey: author.to_hex(), - fresh_until, - connection_epoch: epoch.as_str().to_owned(), - }), - ) - .await; - assert!(manager.projection.lock().await.current.is_some()); - - let (client_io, server_io) = duplex(4096); - let (client, mut server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let task = tauri::async_runtime::spawn(run_connection_inner( - 11, - client, - receiver, - handle.cancel.clone(), - silent_channel(), - manager.clone(), - handle.clone(), - Some(ClientBindingStatusSession::new(relay, author, epoch)), - )); - *handle.task.lock().await = Some(task); - server - .send(Message::Text( - serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) - .to_string() - .into(), - )) - .await - .expect("send reserved frame"); - - let entered = tokio::time::timeout(Duration::from_secs(1), async { - while !pause.entered.load(Ordering::SeqCst) { - tokio::task::yield_now().await; - } - }) - .await - .is_ok(); - let visible_after_entering_fold = manager.projection.lock().await.current.is_some(); - let expired = if entered { - tokio::time::timeout(Duration::from_secs(3), async { - while manager.projection.lock().await.current.is_some() { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .is_ok() - } else { - false - }; - - pause.release(); - server - .send(Message::Close(None)) - .await - .expect("close synthetic socket"); - let task = handle.task.lock().await.take().expect("registered task"); - tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("connection loop exits") - .expect("connection task joins"); - - assert!(entered, "reserved fold should reach its blocking section"); - assert!( - visible_after_entering_fold, - "projection should still be visible when the fold blocks" - ); - assert!( - expired, - "deadline must clear while the fold remains blocked" - ); - assert!(unix_now() >= fresh_until); - } - - #[tokio::test] - async fn stale_task_cannot_remove_reused_connection_id() { - let manager = WebSocketManager::default(); - let (old_sender, _old_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let old = Arc::new(ConnectionHandle { - sender: old_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let current = Arc::new(ConnectionHandle { - sender: new_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(9, old.clone()); - manager.connections.lock().await.insert(9, current.clone()); - - manager.remove_if_current(9, &old).await; - assert!(manager - .connections - .lock() - .await - .get(&9) - .is_some_and(|handle| Arc::ptr_eq(handle, ¤t))); - manager.remove_if_current(9, ¤t).await; - assert!(!manager.connections.lock().await.contains_key(&9)); - } - - #[tokio::test] - async fn reserved_text_is_swallowed_but_binary_remains_raw_delivery() { - let manager = WebSocketManager::default(); - let delivered = Arc::new(AtomicUsize::new(0)); - let delivered_for_channel = delivered.clone(); - let channel = Channel::new(move |_: InvokeResponseBody| { - delivered_for_channel.fetch_add(1, Ordering::SeqCst); - Ok(()) - }); - let (client_io, server_io) = duplex(4096); - let (client, mut server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(42, handle.clone()); - let task = tauri::async_runtime::spawn(run_connection( - 42, - client, - receiver, - handle.cancel.clone(), - channel, - manager.clone(), - )); - *handle.task.lock().await = Some(task); - - server - .send(Message::Text( - serde_json::json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]) - .to_string() - .into(), - )) - .await - .expect("send reserved bootstrap frame"); - server - .send(Message::Binary( - serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) - .to_string() - .into_bytes() - .into(), - )) - .await - .expect("send reserved status frame"); - server - .send(Message::Text("ordinary".into())) - .await - .expect("send ordinary frame"); - server - .send(Message::Close(None)) - .await - .expect("close synthetic socket"); - - let task = handle - .task - .lock() - .await - .take() - .expect("connection task is registered"); - tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("connection loop exits") - .expect("connection task joins"); - assert_eq!( - delivered.load(Ordering::SeqCst), - 3, - "binary, ordinary text, and terminal close reach raw delivery" - ); - assert!(!manager.connections.lock().await.contains_key(&42)); - } - - #[test] - fn reserved_classifier_never_intercepts_binary() { - let reserved = - serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]).to_string(); - assert!(reserved_text_message(&Message::Text(reserved.clone().into())).is_some()); - assert!(reserved_text_message(&Message::Binary(reserved.into_bytes().into())).is_none()); - } - - #[test] - fn auth_challenge_recording_requires_exact_frame_shape() { - assert_eq!( - nip42_challenge(&serde_json::json!(["AUTH", "exact"]).to_string()).as_deref(), - Some("exact") - ); - assert!( - nip42_challenge(&serde_json::json!(["AUTH", "exact", "extra"]).to_string()).is_none() - ); - assert!(nip42_challenge("not-json").is_none()); - } -} +#[path = "native_websocket_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/native_websocket_status.rs b/desktop/src-tauri/src/native_websocket_status.rs new file mode 100644 index 0000000000..b3f0415c71 --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_status.rs @@ -0,0 +1,201 @@ +use std::{net::IpAddr, sync::Arc, time::Duration}; + +use futures_util::StreamExt; +use nostr::PublicKey; +use serde::Deserialize; +use tauri::ipc::Channel; +use url::{Host, Url}; + +use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; + +use crate::{ + app_state::AppState, + client_binding_status_session::{ClientBindingStatusSession, CurrentProjection}, +}; + +use super::{ConnectionHandle, Id}; + +const NIP11_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_NIP11_BODY_BYTES: usize = 64 * 1024; + +pub(super) struct StatusScope { + pub(super) relay_url: String, + pub(super) relay_signer: PublicKey, + pub(super) expected_author: PublicKey, + pub(super) epoch: ClientBindingEpoch, + pub(super) projection_channel: Channel, + pub(super) generation: u64, + pub(super) attempt: u64, + pub(super) challenge: Option, + pub(super) auth_proven: bool, +} + +pub(super) struct PreparedStatus { + pub(super) session: ClientBindingStatusSession, + pub(super) scope: StatusScope, +} + +pub(crate) struct StatusAuthProof { + pub(super) handle: Arc, + pub(super) challenge: String, + pub(super) relay_url: String, + pub(super) relay_signer: PublicKey, + pub(super) expected_author: PublicKey, + pub(super) epoch: ClientBindingEpoch, + pub(super) generation: u64, + pub(super) attempt: u64, +} + +impl StatusAuthProof { + pub(crate) fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.epoch + } + + pub(crate) const fn relay_signer(&self) -> PublicKey { + self.relay_signer + } +} + +pub(super) struct ProjectionOwner { + pub(super) id: Id, + pub(super) handle: Arc, + pub(super) epoch: ClientBindingEpoch, + pub(super) attempt: u64, + pub(super) presentation_token: u64, + pub(super) channel: Channel, +} + +#[derive(Default)] +pub(super) struct ProjectionState { + pub(super) generation: u64, + pub(super) attempt_head: u64, + pub(super) mutation_depth: u64, + pub(super) suspended: bool, + pub(super) owner: Option, + pub(super) current: Option, +} + +pub(super) async fn prepare_status_session( + state: &AppState, + requested_url: &str, + projection_channel: Channel, + generation: u64, + attempt: u64, +) -> Option { + if requested_url != crate::relay::relay_ws_url_with_override(state) { + return None; + } + let expected_author = state.signing_keys().ok()?.public_key(); + let relay_signer = fetch_nip11_signer(requested_url).await.ok()?; + let epoch = ClientBindingEpoch::new_v4(); + Some(PreparedStatus { + session: ClientBindingStatusSession::new(relay_signer, expected_author, epoch.clone()), + scope: StatusScope { + relay_url: requested_url.to_owned(), + relay_signer, + expected_author, + epoch, + projection_channel, + generation, + attempt, + challenge: None, + auth_proven: false, + }, + }) +} + +#[derive(Deserialize)] +struct Nip11Identity { + #[serde(rename = "self")] + relay_self: String, +} + +async fn fetch_nip11_signer(relay_url: &str) -> Result { + let url = nip11_url(relay_url)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(NIP11_TIMEOUT) + .build() + .map_err(|_| "NIP-11 unavailable".to_string())?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + .map_err(|_| "NIP-11 unavailable".to_string())?; + if !response.status().is_success() + || response + .content_length() + .is_some_and(|length| length > MAX_NIP11_BODY_BYTES as u64) + { + return Err("NIP-11 unavailable".to_string()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "NIP-11 unavailable".to_string())?; + if body.len().saturating_add(chunk.len()) > MAX_NIP11_BODY_BYTES { + return Err("NIP-11 unavailable".to_string()); + } + body.extend_from_slice(&chunk); + } + let identity: Nip11Identity = + serde_json::from_slice(&body).map_err(|_| "NIP-11 unavailable".to_string())?; + if identity.relay_self.len() != 64 + || !identity + .relay_self + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("NIP-11 unavailable".to_string()); + } + PublicKey::from_hex(&identity.relay_self).map_err(|_| "NIP-11 unavailable".to_string()) +} + +pub(super) fn nip11_url(relay_url: &str) -> Result { + let mut url = Url::parse(relay_url).map_err(|_| "NIP-11 unavailable".to_string())?; + match url.scheme() { + "wss" => url + .set_scheme("https") + .map_err(|_| "NIP-11 unavailable".to_string())?, + "ws" if is_loopback_url(&url) => url + .set_scheme("http") + .map_err(|_| "NIP-11 unavailable".to_string())?, + _ => return Err("NIP-11 unavailable".to_string()), + } + Ok(url) +} + +pub(super) fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(), + Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(), + None => false, + } +} + + +pub(super) fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +pub(super) fn duration_until_unix_second(unix_second: u64) -> Duration { + let Some(deadline) = std::time::UNIX_EPOCH.checked_add(Duration::from_secs(unix_second)) else { + return Duration::ZERO; + }; + deadline + .duration_since(std::time::SystemTime::now()) + .unwrap_or_default() +} + +pub(super) fn monotonic_deadline_after(delay: Duration) -> tokio::time::Instant { + let now = tokio::time::Instant::now(); + now.checked_add(delay).unwrap_or(now) +} + +pub(super) fn status_expiry_sleep(deadline: tokio::time::Instant) -> tokio::time::Sleep { + tokio::time::sleep_until(deadline) +} diff --git a/desktop/src-tauri/src/native_websocket_tests.rs b/desktop/src-tauri/src/native_websocket_tests.rs new file mode 100644 index 0000000000..a352d701fd --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_tests.rs @@ -0,0 +1,770 @@ + use super::*; + use super::native_websocket_status::{is_loopback_url, nip11_url}; + use crate::client_binding_status_session::CurrentProjection; + use futures_util::FutureExt; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use url::Url; + + use buzz_core_pkg::client_binding_bootstrap::{ + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, + }; + use tauri::ipc::InvokeResponseBody; + use tokio::io::duplex; + use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; + + fn silent_channel() -> Channel { + Channel::new(|_: InvokeResponseBody| Ok(())) + } + + #[tokio::test] + async fn secure_websocket_reaches_tls_without_panicking() { + install_crypto_provider(); + 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(); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + let result = std::panic::AssertUnwindSafe(tokio_tungstenite::connect_async(format!( + "wss://{address}" + ))) + .catch_unwind() + .await; + + assert!(result.is_ok(), "TLS setup must not panic"); + server.await.unwrap(); + } + + #[tokio::test] + async fn live_tcp_server_connect_send_and_disconnect() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (received_tx, received_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let message = socket.next().await.unwrap().unwrap(); + received_tx.send(message).unwrap(); + while let Some(message) = socket.next().await { + if matches!(message, Ok(Message::Close(_))) { + break; + } + } + }); + + let manager = WebSocketManager::default(); + let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) + .await + .unwrap(); + send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), received_rx) + .await + .unwrap() + .unwrap(), + Message::Text("live-probe".into()) + ); + + manager.disconnect(id).await; + assert!(!manager.connections.lock().await.contains_key(&id)); + tokio::time::timeout(Duration::from_secs(1), server) + .await + .expect("live server should observe native socket shutdown") + .unwrap(); + } + + #[tokio::test] + async fn eof_removes_connection() { + let manager = WebSocketManager::default(); + let (client_io, server_io) = duplex(1024); + let (client, server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 1, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + drop(server); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.connections.lock().await.contains_key(&1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("EOF should clean up its native connection ID"); + } + + #[tokio::test] + async fn disconnect_removes_and_drops_task_before_returning() { + struct DropGuard(Arc); + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let manager = WebSocketManager::default(); + let dropped = Arc::new(AtomicBool::new(false)); + let task_dropped = dropped.clone(); + let (ready_tx, ready_rx) = oneshot::channel(); + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(Some(tauri::async_runtime::spawn(async move { + let _guard = DropGuard(task_dropped); + ready_tx.send(()).unwrap(); + std::future::pending::<()>().await; + }))), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(7, handle); + ready_rx.await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), manager.disconnect(7)) + .await + .expect("disconnect should abort an unresponsive task"); + assert!(!manager.connections.lock().await.contains_key(&7)); + assert!(dropped.load(Ordering::SeqCst)); + + // Repeated teardown is intentionally a no-op. + manager.disconnect(7).await; + } + + #[tokio::test] + async fn teardown_gate_stays_closed_until_tasks_stop() { + let manager = WebSocketManager::default(); + let gate = manager.connect_cancel.lock().await; + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(Some(tauri::async_runtime::spawn(async { + std::future::pending::<()>().await; + }))), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, handle); + gate.cancel(); + let handles = { + let mut connections = manager.connections.lock().await; + connections + .drain() + .map(|(_, handle)| handle) + .collect::>() + }; + + let shutdown = futures_util::future::join_all( + handles.into_iter().map(WebSocketManager::disconnect_handle), + ); + assert!(manager.connect_cancel.try_lock().is_err()); + shutdown.await; + drop(gate); + assert!(manager.connect_cancel.try_lock().is_ok()); + } + + #[tokio::test] + async fn disconnect_all_cancels_pending_connect_generation() { + let manager = WebSocketManager::default(); + let pending = manager.current_connect_cancel().await; + let generation = manager.projection_generation().await; + + manager.disconnect_all().await; + + assert!(pending.is_cancelled()); + assert!(!manager.current_connect_cancel().await.is_cancelled()); + assert_ne!(manager.projection_generation().await, generation); + } + + #[tokio::test] + async fn one_connection_does_not_block_another_send_queue() { + let manager = WebSocketManager::default(); + let (blocked_sender, blocked_receiver) = mpsc::channel(1); + blocked_sender + .send(SendRequest { + message: Message::Text("blocked".into()), + result: oneshot::channel().0, + }) + .await + .unwrap(); + let blocked = Arc::new(ConnectionHandle { + sender: blocked_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, blocked); + + let (healthy_sender, mut healthy_receiver) = mpsc::channel(1); + let healthy = Arc::new(ConnectionHandle { + sender: healthy_sender.clone(), + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(2, healthy); + + let (result, _) = oneshot::channel(); + tokio::time::timeout( + Duration::from_millis(50), + healthy_sender.send(SendRequest { + message: Message::Text("healthy".into()), + result, + }), + ) + .await + .expect("a full queue on one connection must not block another") + .unwrap(); + assert!(healthy_receiver.recv().await.is_some()); + drop(blocked_receiver); + } + + #[test] + fn nip11_url_accepts_tls_or_loopback_only() { + assert_eq!( + nip11_url("wss://relay.example.test/community?view=1") + .expect("secure relay URL is eligible") + .as_str(), + "https://relay.example.test/community?view=1" + ); + assert_eq!( + nip11_url("ws://localhost:3000/") + .expect("localhost relay URL is eligible") + .as_str(), + "http://localhost:3000/" + ); + assert!(nip11_url("ws://127.0.0.1:3000/").is_ok()); + assert!(nip11_url("ws://[::1]:3000/").is_ok()); + assert!(nip11_url("ws://relay.example.test/").is_err()); + assert!(nip11_url("http://localhost:3000/").is_err()); + + assert!(is_loopback_url( + &Url::parse("ws://LOCALHOST:3000/").expect("test URL") + )); + assert!(!is_loopback_url( + &Url::parse("ws://localhost.example.test/").expect("test URL") + )); + } + + fn test_epoch(suffix: u8) -> ClientBindingEpoch { + ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{suffix:012x}")) + .expect("synthetic epoch") + } + + fn test_handle(status_scope: Option) -> Arc { + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(status_scope), + fold_pause: None, + }) + } + + fn test_status_scope( + generation: u64, + attempt: u64, + relay: PublicKey, + author: PublicKey, + epoch: ClientBindingEpoch, + ) -> StatusScope { + StatusScope { + relay_url: "ws://localhost:3000/".to_string(), + relay_signer: relay, + expected_author: author, + epoch, + projection_channel: silent_channel(), + generation, + attempt, + challenge: None, + auth_proven: false, + } + } + + #[tokio::test] + async fn only_proven_status_socket_owns_projection_and_old_handle_is_fenced() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, old_attempt) = manager.begin_status_attempt().await.unwrap(); + let old_epoch = test_epoch(0x11); + let old = test_handle(Some(test_status_scope( + generation, + old_attempt, + relay, + author, + old_epoch.clone(), + ))); + manager.connections.lock().await.insert(7, old.clone()); + manager + .record_status_challenge(7, &old, "challenge-old") + .await; + let old_proof = manager + .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + + assert!(manager + .status_auth_proof(7, "challenge-old", "ws://wrong/", author) + .await + .is_err()); + + let (new_generation, new_attempt) = manager.begin_status_attempt().await.unwrap(); + assert_eq!(new_generation, generation); + assert!(new_attempt > old_attempt); + let new_epoch = test_epoch(0x22); + let new = test_handle(Some(test_status_scope( + new_generation, + new_attempt, + relay, + author, + new_epoch.clone(), + ))); + manager.connections.lock().await.insert(8, new.clone()); + manager + .record_status_challenge(8, &new, "challenge-new") + .await; + let new_proof = manager + .status_auth_proof(8, "challenge-new", "ws://localhost:3000/", author) + .await + .expect("replacement proof"); + manager + .complete_status_auth(8, &new_proof) + .await + .expect("replacement owns projection"); + + // An older eligible attempt cannot replace a newer owner even when its + // exact proof was captured before the newer attempt completed. + assert!(manager.complete_status_auth(7, &old_proof).await.is_err()); + + let current = CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until: unix_now() + 60, + connection_epoch: new_epoch.as_str().to_owned(), + }; + manager + .apply_projection_update( + 8, + &new, + &new_epoch, + ProjectionUpdate::Current(current.clone()), + ) + .await; + assert_eq!( + manager.projection.lock().await.current, + Some(current.clone()) + ); + + manager.clear_projection_if_owner(7, &old, &old_epoch).await; + manager + .apply_projection_update( + 7, + &old, + &old_epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: u64::MAX, + connection_epoch: old_epoch.as_str().to_owned(), + }), + ) + .await; + assert_eq!(manager.projection.lock().await.current, Some(current)); + + let read_only = test_handle(None); + manager + .connections + .lock() + .await + .insert(9, read_only.clone()); + assert!(manager + .status_auth_proof(9, "challenge", "ws://localhost:3000/", author) + .await + .is_err()); + + manager.invalidate_projection().await; + assert!(new.cancel.is_cancelled()); + assert!(manager.projection.lock().await.owner.is_none()); + assert!(manager.complete_status_auth(8, &new_proof).await.is_err()); + manager.suspend_projection().await; + assert!(manager.status_head().await.is_none()); + } + + #[tokio::test] + async fn overlapping_scope_mutations_keep_status_fail_closed() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x33); + let handle = test_handle(Some(test_status_scope( + generation, attempt, relay, author, epoch, + ))); + manager.connections.lock().await.insert(10, handle.clone()); + manager + .record_status_challenge(10, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(10, "challenge", "ws://localhost:3000/", author) + .await + .expect("pre-mutation proof"); + + manager.begin_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + assert!(handle.cancel.is_cancelled()); + manager.begin_scope_mutation().await; + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_some()); + assert!(manager.complete_status_auth(10, &proof).await.is_err()); + } + + #[tokio::test] + async fn security_token_exhaustion_fails_closed() { + let manager = WebSocketManager::default(); + manager.projection.lock().await.attempt_head = u64::MAX; + assert!(manager.begin_status_attempt().await.is_none()); + assert!(manager.projection.lock().await.suspended); + + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x34); + let current = CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: unix_now() + 60, + connection_epoch: epoch.as_str().to_owned(), + }; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 12, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 1, + presentation_token: u64::MAX, + channel: silent_channel(), + }); + projection.current = Some(current.clone()); + } + manager + .apply_projection_update(12, &handle, &epoch, ProjectionUpdate::Current(current)) + .await; + let projection = manager.projection.lock().await; + assert!(projection.owner.is_none()); + assert!(projection.current.is_none()); + } + + #[tokio::test] + async fn matching_monotonic_expiry_clears_when_wall_clock_looks_early() { + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x35); + let fresh_until = unix_now() + 60; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 13, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 2, + presentation_token: 3, + channel: silent_channel(), + }); + projection.current = Some(CurrentProjection { + event_author_pubkey: "22".repeat(32), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), + }); + } + + assert!(unix_now() < fresh_until, "test models a backward clock"); + manager + .expire_projection_if_owner(13, &handle, &epoch, 2, 3, fresh_until) + .await; + assert!(manager.projection.lock().await.current.is_none()); + } + + #[tokio::test] + async fn status_expiry_sleep_keeps_deadline_across_delayed_first_poll() { + let deadline = monotonic_deadline_after(Duration::from_millis(1)); + tokio::time::sleep(Duration::from_millis(10)).await; + + let sleep = status_expiry_sleep(deadline); + assert_eq!(sleep.deadline(), deadline); + sleep.await; + + let overflow_deadline = monotonic_deadline_after(Duration::MAX); + assert!(overflow_deadline <= tokio::time::Instant::now()); + } + + #[tokio::test] + async fn projection_expires_while_reserved_fold_is_blocked() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x44); + let pause = Arc::new(TestFoldPause::new()); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(Some(test_status_scope( + generation, + attempt, + relay, + author, + epoch.clone(), + ))), + fold_pause: Some(pause.clone()), + }); + manager.connections.lock().await.insert(11, handle.clone()); + manager + .record_status_challenge(11, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(11, "challenge", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + manager + .complete_status_auth(11, &proof) + .await + .expect("test socket owns projection"); + + let fresh_until = unix_now() + 2; + manager + .apply_projection_update( + 11, + &handle, + &epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_some()); + + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let task = tauri::async_runtime::spawn(run_connection_inner( + 11, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + handle.clone(), + Some(ClientBindingStatusSession::new(relay, author, epoch)), + )); + *handle.task.lock().await = Some(task); + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into(), + )) + .await + .expect("send reserved frame"); + + let entered = tokio::time::timeout(Duration::from_secs(1), async { + while !pause.entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .is_ok(); + let visible_after_entering_fold = manager.projection.lock().await.current.is_some(); + let expired = if entered { + tokio::time::timeout(Duration::from_secs(3), async { + while manager.projection.lock().await.current.is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .is_ok() + } else { + false + }; + + pause.release(); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + let task = handle.task.lock().await.take().expect("registered task"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + + assert!(entered, "reserved fold should reach its blocking section"); + assert!( + visible_after_entering_fold, + "projection should still be visible when the fold blocks" + ); + assert!( + expired, + "deadline must clear while the fold remains blocked" + ); + assert!(unix_now() >= fresh_until); + } + + #[tokio::test] + async fn stale_task_cannot_remove_reused_connection_id() { + let manager = WebSocketManager::default(); + let (old_sender, _old_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let old = Arc::new(ConnectionHandle { + sender: old_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let current = Arc::new(ConnectionHandle { + sender: new_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(9, old.clone()); + manager.connections.lock().await.insert(9, current.clone()); + + manager.remove_if_current(9, &old).await; + assert!(manager + .connections + .lock() + .await + .get(&9) + .is_some_and(|handle| Arc::ptr_eq(handle, ¤t))); + manager.remove_if_current(9, ¤t).await; + assert!(!manager.connections.lock().await.contains_key(&9)); + } + + #[tokio::test] + async fn reserved_text_is_swallowed_but_binary_remains_raw_delivery() { + let manager = WebSocketManager::default(); + let delivered = Arc::new(AtomicUsize::new(0)); + let delivered_for_channel = delivered.clone(); + let channel = Channel::new(move |_: InvokeResponseBody| { + delivered_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(42, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 42, + client, + receiver, + handle.cancel.clone(), + channel, + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]) + .to_string() + .into(), + )) + .await + .expect("send reserved bootstrap frame"); + server + .send(Message::Binary( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into_bytes() + .into(), + )) + .await + .expect("send reserved status frame"); + server + .send(Message::Text("ordinary".into())) + .await + .expect("send ordinary frame"); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + + let task = handle + .task + .lock() + .await + .take() + .expect("connection task is registered"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + assert_eq!( + delivered.load(Ordering::SeqCst), + 3, + "binary, ordinary text, and terminal close reach raw delivery" + ); + assert!(!manager.connections.lock().await.contains_key(&42)); + } + + #[test] + fn reserved_classifier_never_intercepts_binary() { + let reserved = + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]).to_string(); + assert!(reserved_text_message(&Message::Text(reserved.clone().into())).is_some()); + assert!(reserved_text_message(&Message::Binary(reserved.into_bytes().into())).is_none()); + } + + #[test] + fn auth_challenge_recording_requires_exact_frame_shape() { + assert_eq!( + nip42_challenge(&serde_json::json!(["AUTH", "exact"]).to_string()).as_deref(), + Some("exact") + ); + assert!( + nip42_challenge(&serde_json::json!(["AUTH", "exact", "extra"]).to_string()).is_none() + ); + assert!(nip42_challenge("not-json").is_none()); + } diff --git a/desktop/src-tauri/tests/current_binding_status_native_flow.rs b/desktop/src-tauri/tests/current_binding_status_native_flow.rs index 90fac422b7..3661ca4ef8 100644 --- a/desktop/src-tauri/tests/current_binding_status_native_flow.rs +++ b/desktop/src-tauri/tests/current_binding_status_native_flow.rs @@ -61,7 +61,7 @@ mod native_websocket { pub(super) async fn current_projection_for_test( manager: &WebSocketManager, - ) -> Option { + ) -> Option { manager.projection.lock().await.current.clone() } diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 89ef15925e..9677a18d43 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -1,9 +1,5 @@ import { Channel, invoke } from "@tauri-apps/api/core"; -import { - createAuthEvent, - getRelayWsUrl, - signRelayEvent, -} from "@/shared/api/tauri"; +import { getRelayWsUrl, signRelayEvent } from "@/shared/api/tauri"; import type { PresenceStatus, RelayEvent } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE, @@ -67,18 +63,8 @@ import { } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; +import { RelayClientStatusConnection } from "@/shared/api/relayClientStatusConnection"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; -import { - clearCurrentProjection, - createCurrentProjectionChannel, - type CurrentProjection, -} from "@/features/binding-status/currentProjectionStore"; - -type NativeSocketBinding = Readonly<{ - id: number; - relayUrl: string; -}>; - export class RelayClient { private wsId: number | null = null; private relayUrl: string | null = null; @@ -101,7 +87,7 @@ export class RelayClient { private hasConnectedOnce = false; private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; - private onProjectionChannel: Channel | null = null; + private statusConnection: RelayClientStatusConnection | null = null; private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; @@ -183,8 +169,8 @@ export class RelayClient { this.reconnectListeners.clear(); this.connectionStateEmitter.clear(); this.onMessageChannel = null; - this.onProjectionChannel = null; - clearCurrentProjection(); + this.statusConnection?.retire(); + this.statusConnection = null; this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; } @@ -542,29 +528,13 @@ export class RelayClient { window.clearTimeout(this.stabilityTimer); this.stabilityTimer = null; } - this.connectionStateEmitter.set( this.hasConnectedOnce ? "reconnecting" : "connecting", ); - const generation = ++this.connectionGeneration; - let nativeWebsocketId: number | null = null; - let resolveNativeSocketBinding!: ( - binding: NativeSocketBinding | null, - ) => void; - const nativeSocketBinding = new Promise( - (resolve) => { - resolveNativeSocketBinding = resolve; - }, - ); - let nativeSocketBindingSettled = false; - const settleNativeSocketBinding = (binding: NativeSocketBinding | null) => { - if (nativeSocketBindingSettled) return; - nativeSocketBindingSettled = true; - resolveNativeSocketBinding(binding); - }; + let statusConnection!: RelayClientStatusConnection; this.onMessageChannel = new Channel((message) => { - void this.handleWsMessage(message, generation, nativeSocketBinding).catch( + void this.handleWsMessage(message, generation, statusConnection).catch( (error) => { if (generation !== this.connectionGeneration) return; this.resetConnection( @@ -573,40 +543,37 @@ export class RelayClient { }, ); }); - let onProjectionChannel: Channel; - onProjectionChannel = createCurrentProjectionChannel( - () => + statusConnection = new RelayClientStatusConnection( + (id) => + generation === this.connectionGeneration && + this.wsId === id && + this.statusConnection === statusConnection, + (id) => generation === this.connectionGeneration && - nativeWebsocketId !== null && - this.wsId === nativeWebsocketId && - this.onProjectionChannel === onProjectionChannel, + this.wsId === id && + this.authRequest !== null, + (eventId) => { + if (this.authRequest) this.authRequest.pendingEventId = eventId; + }, + (event) => this.sendRaw(["AUTH", event]), ); - this.onProjectionChannel = onProjectionChannel; - clearCurrentProjection(); - + this.statusConnection = statusConnection; try { if (!this.relayUrl) { this.relayUrl = await getRelayWsUrl(); } const connectionRelayUrl = this.relayUrl; - const wsId = await invoke( - "plugin:websocket|connect_with_status", - { - url: connectionRelayUrl, - onMessage: this.onMessageChannel, - onProjection: onProjectionChannel, - config: {}, - }, + const wsId = await statusConnection.connect( + connectionRelayUrl, + this.onMessageChannel, ); if (generation !== this.connectionGeneration) { - settleNativeSocketBinding(null); + statusConnection.retire(); void closeWebSocket(wsId, "stale connection attempt"); throw new Error("Relay connection attempt was superseded."); } - nativeWebsocketId = wsId; this.wsId = wsId; - settleNativeSocketBinding({ id: wsId, relayUrl: connectionRelayUrl }); - + statusConnection.bind(wsId, connectionRelayUrl); await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { const error = new Error("Relay authentication timed out."); @@ -614,7 +581,6 @@ export class RelayClient { this.resetConnection(error); reject(error); }, AUTH_TIMEOUT_MS); - this.authRequest = { pendingEventId: "", resolve, @@ -622,18 +588,16 @@ export class RelayClient { timeout, }; }); - this.stabilityTimer = window.setTimeout(() => { this.stabilityTimer = null; this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; }, BACKOFF_RESET_STABLE_MS); - this.connectionStateEmitter.set("connected"); await this.replayLiveSubscriptions(); this.stallWatchdog.start(); this.emitReconnectIfNeeded(); } catch (error) { - settleNativeSocketBinding(null); + statusConnection.retire(); const connectionError = this.normalizeRelayError( error, "Failed to connect to relay.", @@ -644,7 +608,6 @@ export class RelayClient { throw connectionError; } } - private async subscribe( filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, @@ -805,7 +768,7 @@ export class RelayClient { private async handleWsMessage( message: unknown, generation: number, - nativeSocketBinding: Promise, + statusConnection: RelayClientStatusConnection, ) { if (generation !== this.connectionGeneration) return; this.stallWatchdog.recordInbound(); @@ -839,9 +802,7 @@ export class RelayClient { const [type, ...rest] = data; if (type === "AUTH" && typeof rest[0] === "string") { - const binding = await nativeSocketBinding; - if (!binding || generation !== this.connectionGeneration) return; - await this.handleAuthChallenge(rest[0], generation, binding); + await statusConnection.handleAuthChallenge(rest[0]); return; } if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) { @@ -890,29 +851,6 @@ export class RelayClient { } } - private async handleAuthChallenge( - challenge: string, - generation: number, - nativeSocketBinding: NativeSocketBinding, - ) { - const event = await createAuthEvent({ - challenge, - nativeWebsocketId: nativeSocketBinding.id, - relayUrl: nativeSocketBinding.relayUrl, - }); - - if ( - generation !== this.connectionGeneration || - this.wsId !== nativeSocketBinding.id || - !this.authRequest - ) { - return; - } - - this.authRequest.pendingEventId = event.id; - await this.sendRaw(["AUTH", event]); - } - private handleEvent(subId: string, event: RelayEvent) { const subscription = this.subscriptions.get(subId); if (!subscription) { @@ -1074,8 +1012,8 @@ export class RelayClient { }, ) { this.onMessageChannel = null; - this.onProjectionChannel = null; - clearCurrentProjection(); + this.statusConnection?.retire(); + this.statusConnection = null; this.stallWatchdog.stop(); this.connectionGeneration++; if (this.stabilityTimer !== null) { @@ -1146,5 +1084,4 @@ export class RelayClient { this.scheduleReconnect(); } } - } diff --git a/desktop/src/shared/api/relayClientStatusConnection.ts b/desktop/src/shared/api/relayClientStatusConnection.ts new file mode 100644 index 0000000000..3af61168a5 --- /dev/null +++ b/desktop/src/shared/api/relayClientStatusConnection.ts @@ -0,0 +1,89 @@ +import { type Channel, invoke } from "@tauri-apps/api/core"; +import { createAuthEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { + clearCurrentProjection, + createCurrentProjectionChannel, + type CurrentProjection, +} from "@/features/binding-status/currentProjectionStore"; + +type NativeSocketBinding = Readonly<{ + id: number; + relayUrl: string; +}>; + +export class RelayClientStatusConnection { + readonly projectionChannel: Channel; + private readonly nativeSocketBinding: Promise; + private resolveNativeSocketBinding!: ( + binding: NativeSocketBinding | null, + ) => void; + private nativeSocketId: number | null = null; + private settled = false; + private readonly isActive: (nativeSocketId: number) => boolean; + private readonly isAuthActive: (nativeSocketId: number) => boolean; + private readonly setPendingEventId: (eventId: string) => void; + private readonly sendAuth: (event: RelayEvent) => Promise; + + constructor( + isActive: (nativeSocketId: number) => boolean, + isAuthActive: (nativeSocketId: number) => boolean, + setPendingEventId: (eventId: string) => void, + sendAuth: (event: RelayEvent) => Promise, + ) { + this.isActive = isActive; + this.isAuthActive = isAuthActive; + this.setPendingEventId = setPendingEventId; + this.sendAuth = sendAuth; + this.nativeSocketBinding = new Promise((resolve) => { + this.resolveNativeSocketBinding = resolve; + }); + this.projectionChannel = createCurrentProjectionChannel( + () => this.nativeSocketId !== null && this.isActive(this.nativeSocketId), + ); + clearCurrentProjection(); + } + + async connect( + relayUrl: string, + onMessage: Channel, + ): Promise { + const id = await invoke("plugin:websocket|connect_with_status", { + url: relayUrl, + onMessage, + onProjection: this.projectionChannel, + config: {}, + }); + return id; + } + + bind(id: number, relayUrl: string) { + this.nativeSocketId = id; + this.settle({ id, relayUrl }); + } + + retire() { + this.settle(null); + this.nativeSocketId = null; + clearCurrentProjection(); + } + + async handleAuthChallenge(challenge: string) { + const binding = await this.nativeSocketBinding; + if (!binding) return; + const event = await createAuthEvent({ + challenge, + nativeWebsocketId: binding.id, + relayUrl: binding.relayUrl, + }); + if (!this.isAuthActive(binding.id)) return; + this.setPendingEventId(event.id); + await this.sendAuth(event); + } + + private settle(binding: NativeSocketBinding | null) { + if (this.settled) return; + this.settled = true; + this.resolveNativeSocketBinding(binding); + } +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 92b43fe617..1bbfa26382 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -665,7 +665,6 @@ export async function signRelayEvent(input: { const eventJson = await invokeTauri("sign_event", input); return JSON.parse(eventJson) as RelayEvent; } - export async function createAuthEvent(input: { challenge: string; nativeWebsocketId?: number; From 10fd9ff8a48675212b10381e4c94caee6d6e1773 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:29:02 -0500 Subject: [PATCH 38/40] test(desktop): satisfy exact CI lint gates Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../connection/j3c_current_binding_wire.rs | 3 + .../j3c_current_binding_relay_harness.rs | 27 +- desktop/src-tauri/src/native_websocket.rs | 7 +- .../src-tauri/src/native_websocket_status.rs | 1 - .../src-tauri/src/native_websocket_tests.rs | 1473 ++++++++--------- 5 files changed, 756 insertions(+), 755 deletions(-) diff --git a/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs index 1fe68f162b..d0df65e58e 100644 --- a/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs +++ b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs @@ -164,6 +164,7 @@ async fn production_outbound_bytes_cross_loopback_into_native_status_session() { .expect("loopback WebSocket client connects"); let mut session = ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + assert_eq!(session.connection_epoch(), &epoch); let bootstrap = ClientBindingBootstrapInputV1::new(domain, author.public_key(), epoch.clone(), now) @@ -259,6 +260,8 @@ async fn production_outbound_bytes_cross_loopback_into_native_status_session() { &epoch, fresh_until, ); + assert!(matches!(session.disconnect(), ProjectionUpdate::Clear)); + assert_eq!(session.projected_fresh_until(), None); cancel.cancel(); timeout(Duration::from_secs(2), server) diff --git a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs index 9312d54b35..75f2266131 100644 --- a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs +++ b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs @@ -1153,22 +1153,25 @@ async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scop assert_eq!(restarted.high_water_revision(), None); let restarted_session = ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + assert_eq!(restarted_session.connection_epoch(), &epoch); assert_eq!(restarted_session.projected_fresh_until(), None); assert_eq!(revisions.current_reads.load(Ordering::SeqCst), 5); assert_eq!(revisions.withdrawal_reads.load(Ordering::SeqCst), 1); - let observed_scopes = revisions - .scopes - .lock() - .expect("synthetic revision scope lock"); - assert!( - !observed_scopes.is_empty(), - "issuer must read durable scope" - ); - assert!(observed_scopes.iter().all(|scope| { - scope.authorization_domain() == domain && scope.event_author_pubkey() == author.public_key() - })); - drop(observed_scopes); + { + let observed_scopes = revisions + .scopes + .lock() + .expect("synthetic revision scope lock"); + assert!( + !observed_scopes.is_empty(), + "issuer must read durable scope" + ); + assert!(observed_scopes.iter().all(|scope| { + scope.authorization_domain() == domain + && scope.event_author_pubkey() == author.public_key() + })); + } connections.deregister(connection_id); drop(expected_frame_tx); diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 159447642a..485c60e677 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,7 +1,7 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; use futures_util::{SinkExt, StreamExt}; use nostr::PublicKey; use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio_tungstenite::{ @@ -13,13 +13,13 @@ use tokio_tungstenite::{ }; use tokio_util::sync::CancellationToken; -use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; use crate::{ app_state::AppState, client_binding_status_session::{ is_reserved_text, ClientBindingStatusSession, ProjectionUpdate, }, }; +use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const WRITE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); @@ -27,11 +27,11 @@ const SEND_QUEUE_CAPACITY: usize = 64; #[path = "native_websocket_status.rs"] mod native_websocket_status; +pub(crate) use native_websocket_status::StatusAuthProof; use native_websocket_status::{ duration_until_unix_second, monotonic_deadline_after, prepare_status_session, status_expiry_sleep, unix_now, PreparedStatus, ProjectionOwner, ProjectionState, StatusScope, }; -pub(crate) use native_websocket_status::StatusAuthProof; pub(crate) fn install_crypto_provider() { // Dependencies enable both rustls providers; choose one before TLS setup. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -962,7 +962,6 @@ fn nip42_challenge(text: &str) -> Option { values.get(1)?.as_str().map(str::to_owned) } - fn outbound_message(message: Message) -> OutboundMessage { match message { Message::Text(value) => OutboundMessage::Text(value.to_string()), diff --git a/desktop/src-tauri/src/native_websocket_status.rs b/desktop/src-tauri/src/native_websocket_status.rs index b3f0415c71..8877142b7a 100644 --- a/desktop/src-tauri/src/native_websocket_status.rs +++ b/desktop/src-tauri/src/native_websocket_status.rs @@ -175,7 +175,6 @@ pub(super) fn is_loopback_url(url: &Url) -> bool { } } - pub(super) fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/desktop/src-tauri/src/native_websocket_tests.rs b/desktop/src-tauri/src/native_websocket_tests.rs index a352d701fd..efd5a6c0fd 100644 --- a/desktop/src-tauri/src/native_websocket_tests.rs +++ b/desktop/src-tauri/src/native_websocket_tests.rs @@ -1,770 +1,767 @@ - use super::*; - use super::native_websocket_status::{is_loopback_url, nip11_url}; - use crate::client_binding_status_session::CurrentProjection; - use futures_util::FutureExt; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use url::Url; - - use buzz_core_pkg::client_binding_bootstrap::{ - CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, - }; - use tauri::ipc::InvokeResponseBody; - use tokio::io::duplex; - use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; - - fn silent_channel() -> Channel { - Channel::new(|_: InvokeResponseBody| Ok(())) - } - - #[tokio::test] - async fn secure_websocket_reaches_tls_without_panicking() { - install_crypto_provider(); - 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(); - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let result = std::panic::AssertUnwindSafe(tokio_tungstenite::connect_async(format!( - "wss://{address}" - ))) - .catch_unwind() - .await; - - assert!(result.is_ok(), "TLS setup must not panic"); - server.await.unwrap(); - } +use super::native_websocket_status::{is_loopback_url, nip11_url}; +use super::*; +use crate::client_binding_status_session::CurrentProjection; +use futures_util::FutureExt; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use url::Url; + +use buzz_core_pkg::client_binding_bootstrap::{ + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, +}; +use tauri::ipc::InvokeResponseBody; +use tokio::io::duplex; +use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; + +fn silent_channel() -> Channel { + Channel::new(|_: InvokeResponseBody| Ok(())) +} + +#[tokio::test] +async fn secure_websocket_reaches_tls_without_panicking() { + install_crypto_provider(); + 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(); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + let result = + std::panic::AssertUnwindSafe(tokio_tungstenite::connect_async(format!("wss://{address}"))) + .catch_unwind() + .await; - #[tokio::test] - async fn live_tcp_server_connect_send_and_disconnect() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let (received_tx, received_rx) = oneshot::channel(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); - let message = socket.next().await.unwrap().unwrap(); - received_tx.send(message).unwrap(); - while let Some(message) = socket.next().await { - if matches!(message, Ok(Message::Close(_))) { - break; - } + assert!(result.is_ok(), "TLS setup must not panic"); + server.await.unwrap(); +} + +#[tokio::test] +async fn live_tcp_server_connect_send_and_disconnect() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (received_tx, received_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let message = socket.next().await.unwrap().unwrap(); + received_tx.send(message).unwrap(); + while let Some(message) = socket.next().await { + if matches!(message, Ok(Message::Close(_))) { + break; } - }); + } + }); - let manager = WebSocketManager::default(); - let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) - .await - .unwrap(); - send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) - .await - .unwrap(); - assert_eq!( - tokio::time::timeout(Duration::from_secs(1), received_rx) - .await - .unwrap() - .unwrap(), - Message::Text("live-probe".into()) - ); - - manager.disconnect(id).await; - assert!(!manager.connections.lock().await.contains_key(&id)); - tokio::time::timeout(Duration::from_secs(1), server) + let manager = WebSocketManager::default(); + let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) + .await + .unwrap(); + send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), received_rx) .await - .expect("live server should observe native socket shutdown") - .unwrap(); + .unwrap() + .unwrap(), + Message::Text("live-probe".into()) + ); + + manager.disconnect(id).await; + assert!(!manager.connections.lock().await.contains_key(&id)); + tokio::time::timeout(Duration::from_secs(1), server) + .await + .expect("live server should observe native socket shutdown") + .unwrap(); +} + +#[tokio::test] +async fn eof_removes_connection() { + let manager = WebSocketManager::default(); + let (client_io, server_io) = duplex(1024); + let (client, server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 1, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + drop(server); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.connections.lock().await.contains_key(&1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("EOF should clean up its native connection ID"); +} + +#[tokio::test] +async fn disconnect_removes_and_drops_task_before_returning() { + struct DropGuard(Arc); + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } } - #[tokio::test] - async fn eof_removes_connection() { - let manager = WebSocketManager::default(); - let (client_io, server_io) = duplex(1024); - let (client, server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(1, handle.clone()); - let task = tauri::async_runtime::spawn(run_connection( - 1, - client, - receiver, - handle.cancel.clone(), - silent_channel(), - manager.clone(), - )); - *handle.task.lock().await = Some(task); - - drop(server); - tokio::time::timeout(Duration::from_secs(1), async { - while manager.connections.lock().await.contains_key(&1) { - tokio::task::yield_now().await; - } + let manager = WebSocketManager::default(); + let dropped = Arc::new(AtomicBool::new(false)); + let task_dropped = dropped.clone(); + let (ready_tx, ready_rx) = oneshot::channel(); + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(Some(tauri::async_runtime::spawn(async move { + let _guard = DropGuard(task_dropped); + ready_tx.send(()).unwrap(); + std::future::pending::<()>().await; + }))), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(7, handle); + ready_rx.await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), manager.disconnect(7)) + .await + .expect("disconnect should abort an unresponsive task"); + assert!(!manager.connections.lock().await.contains_key(&7)); + assert!(dropped.load(Ordering::SeqCst)); + + // Repeated teardown is intentionally a no-op. + manager.disconnect(7).await; +} + +#[tokio::test] +async fn teardown_gate_stays_closed_until_tasks_stop() { + let manager = WebSocketManager::default(); + let gate = manager.connect_cancel.lock().await; + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(Some(tauri::async_runtime::spawn(async { + std::future::pending::<()>().await; + }))), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, handle); + gate.cancel(); + let handles = { + let mut connections = manager.connections.lock().await; + connections + .drain() + .map(|(_, handle)| handle) + .collect::>() + }; + + let shutdown = futures_util::future::join_all( + handles.into_iter().map(WebSocketManager::disconnect_handle), + ); + assert!(manager.connect_cancel.try_lock().is_err()); + shutdown.await; + drop(gate); + assert!(manager.connect_cancel.try_lock().is_ok()); +} + +#[tokio::test] +async fn disconnect_all_cancels_pending_connect_generation() { + let manager = WebSocketManager::default(); + let pending = manager.current_connect_cancel().await; + let generation = manager.projection_generation().await; + + manager.disconnect_all().await; + + assert!(pending.is_cancelled()); + assert!(!manager.current_connect_cancel().await.is_cancelled()); + assert_ne!(manager.projection_generation().await, generation); +} + +#[tokio::test] +async fn one_connection_does_not_block_another_send_queue() { + let manager = WebSocketManager::default(); + let (blocked_sender, blocked_receiver) = mpsc::channel(1); + blocked_sender + .send(SendRequest { + message: Message::Text("blocked".into()), + result: oneshot::channel().0, }) .await - .expect("EOF should clean up its native connection ID"); + .unwrap(); + let blocked = Arc::new(ConnectionHandle { + sender: blocked_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, blocked); + + let (healthy_sender, mut healthy_receiver) = mpsc::channel(1); + let healthy = Arc::new(ConnectionHandle { + sender: healthy_sender.clone(), + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(2, healthy); + + let (result, _) = oneshot::channel(); + tokio::time::timeout( + Duration::from_millis(50), + healthy_sender.send(SendRequest { + message: Message::Text("healthy".into()), + result, + }), + ) + .await + .expect("a full queue on one connection must not block another") + .unwrap(); + assert!(healthy_receiver.recv().await.is_some()); + drop(blocked_receiver); +} + +#[test] +fn nip11_url_accepts_tls_or_loopback_only() { + assert_eq!( + nip11_url("wss://relay.example.test/community?view=1") + .expect("secure relay URL is eligible") + .as_str(), + "https://relay.example.test/community?view=1" + ); + assert_eq!( + nip11_url("ws://localhost:3000/") + .expect("localhost relay URL is eligible") + .as_str(), + "http://localhost:3000/" + ); + assert!(nip11_url("ws://127.0.0.1:3000/").is_ok()); + assert!(nip11_url("ws://[::1]:3000/").is_ok()); + assert!(nip11_url("ws://relay.example.test/").is_err()); + assert!(nip11_url("http://localhost:3000/").is_err()); + + assert!(is_loopback_url( + &Url::parse("ws://LOCALHOST:3000/").expect("test URL") + )); + assert!(!is_loopback_url( + &Url::parse("ws://localhost.example.test/").expect("test URL") + )); +} + +fn test_epoch(suffix: u8) -> ClientBindingEpoch { + ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{suffix:012x}")) + .expect("synthetic epoch") +} + +fn test_handle(status_scope: Option) -> Arc { + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(status_scope), + fold_pause: None, + }) +} + +fn test_status_scope( + generation: u64, + attempt: u64, + relay: PublicKey, + author: PublicKey, + epoch: ClientBindingEpoch, +) -> StatusScope { + StatusScope { + relay_url: "ws://localhost:3000/".to_string(), + relay_signer: relay, + expected_author: author, + epoch, + projection_channel: silent_channel(), + generation, + attempt, + challenge: None, + auth_proven: false, } +} + +#[tokio::test] +async fn only_proven_status_socket_owns_projection_and_old_handle_is_fenced() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, old_attempt) = manager.begin_status_attempt().await.unwrap(); + let old_epoch = test_epoch(0x11); + let old = test_handle(Some(test_status_scope( + generation, + old_attempt, + relay, + author, + old_epoch.clone(), + ))); + manager.connections.lock().await.insert(7, old.clone()); + manager + .record_status_challenge(7, &old, "challenge-old") + .await; + let old_proof = manager + .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); - #[tokio::test] - async fn disconnect_removes_and_drops_task_before_returning() { - struct DropGuard(Arc); - impl Drop for DropGuard { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - let manager = WebSocketManager::default(); - let dropped = Arc::new(AtomicBool::new(false)); - let task_dropped = dropped.clone(); - let (ready_tx, ready_rx) = oneshot::channel(); - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(Some(tauri::async_runtime::spawn(async move { - let _guard = DropGuard(task_dropped); - ready_tx.send(()).unwrap(); - std::future::pending::<()>().await; - }))), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(7, handle); - ready_rx.await.unwrap(); + assert!(manager + .status_auth_proof(7, "challenge-old", "ws://wrong/", author) + .await + .is_err()); + + let (new_generation, new_attempt) = manager.begin_status_attempt().await.unwrap(); + assert_eq!(new_generation, generation); + assert!(new_attempt > old_attempt); + let new_epoch = test_epoch(0x22); + let new = test_handle(Some(test_status_scope( + new_generation, + new_attempt, + relay, + author, + new_epoch.clone(), + ))); + manager.connections.lock().await.insert(8, new.clone()); + manager + .record_status_challenge(8, &new, "challenge-new") + .await; + let new_proof = manager + .status_auth_proof(8, "challenge-new", "ws://localhost:3000/", author) + .await + .expect("replacement proof"); + manager + .complete_status_auth(8, &new_proof) + .await + .expect("replacement owns projection"); - tokio::time::timeout(Duration::from_secs(1), manager.disconnect(7)) - .await - .expect("disconnect should abort an unresponsive task"); - assert!(!manager.connections.lock().await.contains_key(&7)); - assert!(dropped.load(Ordering::SeqCst)); + // An older eligible attempt cannot replace a newer owner even when its + // exact proof was captured before the newer attempt completed. + assert!(manager.complete_status_auth(7, &old_proof).await.is_err()); - // Repeated teardown is intentionally a no-op. - manager.disconnect(7).await; - } + let current = CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until: unix_now() + 60, + connection_epoch: new_epoch.as_str().to_owned(), + }; + manager + .apply_projection_update( + 8, + &new, + &new_epoch, + ProjectionUpdate::Current(current.clone()), + ) + .await; + assert_eq!( + manager.projection.lock().await.current, + Some(current.clone()) + ); + + manager.clear_projection_if_owner(7, &old, &old_epoch).await; + manager + .apply_projection_update( + 7, + &old, + &old_epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: u64::MAX, + connection_epoch: old_epoch.as_str().to_owned(), + }), + ) + .await; + assert_eq!(manager.projection.lock().await.current, Some(current)); - #[tokio::test] - async fn teardown_gate_stays_closed_until_tasks_stop() { - let manager = WebSocketManager::default(); - let gate = manager.connect_cancel.lock().await; - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(Some(tauri::async_runtime::spawn(async { - std::future::pending::<()>().await; - }))), - status_scope: Mutex::new(None), - fold_pause: None, + let read_only = test_handle(None); + manager + .connections + .lock() + .await + .insert(9, read_only.clone()); + assert!(manager + .status_auth_proof(9, "challenge", "ws://localhost:3000/", author) + .await + .is_err()); + + manager.invalidate_projection().await; + assert!(new.cancel.is_cancelled()); + assert!(manager.projection.lock().await.owner.is_none()); + assert!(manager.complete_status_auth(8, &new_proof).await.is_err()); + manager.suspend_projection().await; + assert!(manager.status_head().await.is_none()); +} + +#[tokio::test] +async fn overlapping_scope_mutations_keep_status_fail_closed() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x33); + let handle = test_handle(Some(test_status_scope( + generation, attempt, relay, author, epoch, + ))); + manager.connections.lock().await.insert(10, handle.clone()); + manager + .record_status_challenge(10, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(10, "challenge", "ws://localhost:3000/", author) + .await + .expect("pre-mutation proof"); + + manager.begin_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + assert!(handle.cancel.is_cancelled()); + manager.begin_scope_mutation().await; + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_some()); + assert!(manager.complete_status_auth(10, &proof).await.is_err()); +} + +#[tokio::test] +async fn security_token_exhaustion_fails_closed() { + let manager = WebSocketManager::default(); + manager.projection.lock().await.attempt_head = u64::MAX; + assert!(manager.begin_status_attempt().await.is_none()); + assert!(manager.projection.lock().await.suspended); + + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x34); + let current = CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: unix_now() + 60, + connection_epoch: epoch.as_str().to_owned(), + }; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 12, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 1, + presentation_token: u64::MAX, + channel: silent_channel(), }); - manager.connections.lock().await.insert(1, handle); - gate.cancel(); - let handles = { - let mut connections = manager.connections.lock().await; - connections - .drain() - .map(|(_, handle)| handle) - .collect::>() - }; - - let shutdown = futures_util::future::join_all( - handles.into_iter().map(WebSocketManager::disconnect_handle), - ); - assert!(manager.connect_cancel.try_lock().is_err()); - shutdown.await; - drop(gate); - assert!(manager.connect_cancel.try_lock().is_ok()); - } - - #[tokio::test] - async fn disconnect_all_cancels_pending_connect_generation() { - let manager = WebSocketManager::default(); - let pending = manager.current_connect_cancel().await; - let generation = manager.projection_generation().await; - - manager.disconnect_all().await; - - assert!(pending.is_cancelled()); - assert!(!manager.current_connect_cancel().await.is_cancelled()); - assert_ne!(manager.projection_generation().await, generation); + projection.current = Some(current.clone()); } - - #[tokio::test] - async fn one_connection_does_not_block_another_send_queue() { - let manager = WebSocketManager::default(); - let (blocked_sender, blocked_receiver) = mpsc::channel(1); - blocked_sender - .send(SendRequest { - message: Message::Text("blocked".into()), - result: oneshot::channel().0, - }) - .await - .unwrap(); - let blocked = Arc::new(ConnectionHandle { - sender: blocked_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, + manager + .apply_projection_update(12, &handle, &epoch, ProjectionUpdate::Current(current)) + .await; + let projection = manager.projection.lock().await; + assert!(projection.owner.is_none()); + assert!(projection.current.is_none()); +} + +#[tokio::test] +async fn matching_monotonic_expiry_clears_when_wall_clock_looks_early() { + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x35); + let fresh_until = unix_now() + 60; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 13, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 2, + presentation_token: 3, + channel: silent_channel(), }); - manager.connections.lock().await.insert(1, blocked); - - let (healthy_sender, mut healthy_receiver) = mpsc::channel(1); - let healthy = Arc::new(ConnectionHandle { - sender: healthy_sender.clone(), - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, + projection.current = Some(CurrentProjection { + event_author_pubkey: "22".repeat(32), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), }); - manager.connections.lock().await.insert(2, healthy); - - let (result, _) = oneshot::channel(); - tokio::time::timeout( - Duration::from_millis(50), - healthy_sender.send(SendRequest { - message: Message::Text("healthy".into()), - result, - }), - ) - .await - .expect("a full queue on one connection must not block another") - .unwrap(); - assert!(healthy_receiver.recv().await.is_some()); - drop(blocked_receiver); - } - - #[test] - fn nip11_url_accepts_tls_or_loopback_only() { - assert_eq!( - nip11_url("wss://relay.example.test/community?view=1") - .expect("secure relay URL is eligible") - .as_str(), - "https://relay.example.test/community?view=1" - ); - assert_eq!( - nip11_url("ws://localhost:3000/") - .expect("localhost relay URL is eligible") - .as_str(), - "http://localhost:3000/" - ); - assert!(nip11_url("ws://127.0.0.1:3000/").is_ok()); - assert!(nip11_url("ws://[::1]:3000/").is_ok()); - assert!(nip11_url("ws://relay.example.test/").is_err()); - assert!(nip11_url("http://localhost:3000/").is_err()); - - assert!(is_loopback_url( - &Url::parse("ws://LOCALHOST:3000/").expect("test URL") - )); - assert!(!is_loopback_url( - &Url::parse("ws://localhost.example.test/").expect("test URL") - )); - } - - fn test_epoch(suffix: u8) -> ClientBindingEpoch { - ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{suffix:012x}")) - .expect("synthetic epoch") - } - - fn test_handle(status_scope: Option) -> Arc { - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(status_scope), - fold_pause: None, - }) } - fn test_status_scope( - generation: u64, - attempt: u64, - relay: PublicKey, - author: PublicKey, - epoch: ClientBindingEpoch, - ) -> StatusScope { - StatusScope { - relay_url: "ws://localhost:3000/".to_string(), - relay_signer: relay, - expected_author: author, - epoch, - projection_channel: silent_channel(), + assert!(unix_now() < fresh_until, "test models a backward clock"); + manager + .expire_projection_if_owner(13, &handle, &epoch, 2, 3, fresh_until) + .await; + assert!(manager.projection.lock().await.current.is_none()); +} + +#[tokio::test] +async fn status_expiry_sleep_keeps_deadline_across_delayed_first_poll() { + let deadline = monotonic_deadline_after(Duration::from_millis(1)); + tokio::time::sleep(Duration::from_millis(10)).await; + + let sleep = status_expiry_sleep(deadline); + assert_eq!(sleep.deadline(), deadline); + sleep.await; + + let overflow_deadline = monotonic_deadline_after(Duration::MAX); + assert!(overflow_deadline <= tokio::time::Instant::now()); +} + +#[tokio::test] +async fn projection_expires_while_reserved_fold_is_blocked() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x44); + let pause = Arc::new(TestFoldPause::new()); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(Some(test_status_scope( generation, attempt, - challenge: None, - auth_proven: false, - } - } - - #[tokio::test] - async fn only_proven_status_socket_owns_projection_and_old_handle_is_fenced() { - let manager = WebSocketManager::default(); - let relay = nostr::Keys::generate().public_key(); - let author = nostr::Keys::generate().public_key(); - let (generation, old_attempt) = manager.begin_status_attempt().await.unwrap(); - let old_epoch = test_epoch(0x11); - let old = test_handle(Some(test_status_scope( - generation, - old_attempt, - relay, - author, - old_epoch.clone(), - ))); - manager.connections.lock().await.insert(7, old.clone()); - manager - .record_status_challenge(7, &old, "challenge-old") - .await; - let old_proof = manager - .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) - .await - .expect("exact native proof"); - - assert!(manager - .status_auth_proof(7, "challenge-old", "ws://wrong/", author) - .await - .is_err()); - - let (new_generation, new_attempt) = manager.begin_status_attempt().await.unwrap(); - assert_eq!(new_generation, generation); - assert!(new_attempt > old_attempt); - let new_epoch = test_epoch(0x22); - let new = test_handle(Some(test_status_scope( - new_generation, - new_attempt, relay, author, - new_epoch.clone(), - ))); - manager.connections.lock().await.insert(8, new.clone()); - manager - .record_status_challenge(8, &new, "challenge-new") - .await; - let new_proof = manager - .status_auth_proof(8, "challenge-new", "ws://localhost:3000/", author) - .await - .expect("replacement proof"); - manager - .complete_status_auth(8, &new_proof) - .await - .expect("replacement owns projection"); - - // An older eligible attempt cannot replace a newer owner even when its - // exact proof was captured before the newer attempt completed. - assert!(manager.complete_status_auth(7, &old_proof).await.is_err()); - - let current = CurrentProjection { - event_author_pubkey: author.to_hex(), - fresh_until: unix_now() + 60, - connection_epoch: new_epoch.as_str().to_owned(), - }; - manager - .apply_projection_update( - 8, - &new, - &new_epoch, - ProjectionUpdate::Current(current.clone()), - ) - .await; - assert_eq!( - manager.projection.lock().await.current, - Some(current.clone()) - ); - - manager.clear_projection_if_owner(7, &old, &old_epoch).await; - manager - .apply_projection_update( - 7, - &old, - &old_epoch, - ProjectionUpdate::Current(CurrentProjection { - event_author_pubkey: "11".repeat(32), - fresh_until: u64::MAX, - connection_epoch: old_epoch.as_str().to_owned(), - }), - ) - .await; - assert_eq!(manager.projection.lock().await.current, Some(current)); - - let read_only = test_handle(None); - manager - .connections - .lock() - .await - .insert(9, read_only.clone()); - assert!(manager - .status_auth_proof(9, "challenge", "ws://localhost:3000/", author) - .await - .is_err()); - - manager.invalidate_projection().await; - assert!(new.cancel.is_cancelled()); - assert!(manager.projection.lock().await.owner.is_none()); - assert!(manager.complete_status_auth(8, &new_proof).await.is_err()); - manager.suspend_projection().await; - assert!(manager.status_head().await.is_none()); - } - - #[tokio::test] - async fn overlapping_scope_mutations_keep_status_fail_closed() { - let manager = WebSocketManager::default(); - let relay = nostr::Keys::generate().public_key(); - let author = nostr::Keys::generate().public_key(); - let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); - let epoch = test_epoch(0x33); - let handle = test_handle(Some(test_status_scope( - generation, attempt, relay, author, epoch, - ))); - manager.connections.lock().await.insert(10, handle.clone()); - manager - .record_status_challenge(10, &handle, "challenge") - .await; - let proof = manager - .status_auth_proof(10, "challenge", "ws://localhost:3000/", author) - .await - .expect("pre-mutation proof"); - - manager.begin_scope_mutation().await; - assert!(manager.status_head().await.is_none()); - assert!(handle.cancel.is_cancelled()); - manager.begin_scope_mutation().await; - manager.finish_scope_mutation().await; - assert!(manager.status_head().await.is_none()); - - manager.finish_scope_mutation().await; - assert!(manager.status_head().await.is_some()); - assert!(manager.complete_status_auth(10, &proof).await.is_err()); - } - - #[tokio::test] - async fn security_token_exhaustion_fails_closed() { - let manager = WebSocketManager::default(); - manager.projection.lock().await.attempt_head = u64::MAX; - assert!(manager.begin_status_attempt().await.is_none()); - assert!(manager.projection.lock().await.suspended); - - let manager = WebSocketManager::default(); - let handle = test_handle(None); - let epoch = test_epoch(0x34); - let current = CurrentProjection { - event_author_pubkey: "11".repeat(32), - fresh_until: unix_now() + 60, - connection_epoch: epoch.as_str().to_owned(), - }; - { - let mut projection = manager.projection.lock().await; - projection.owner = Some(ProjectionOwner { - id: 12, - handle: handle.clone(), - epoch: epoch.clone(), - attempt: 1, - presentation_token: u64::MAX, - channel: silent_channel(), - }); - projection.current = Some(current.clone()); - } - manager - .apply_projection_update(12, &handle, &epoch, ProjectionUpdate::Current(current)) - .await; - let projection = manager.projection.lock().await; - assert!(projection.owner.is_none()); - assert!(projection.current.is_none()); - } + epoch.clone(), + ))), + fold_pause: Some(pause.clone()), + }); + manager.connections.lock().await.insert(11, handle.clone()); + manager + .record_status_challenge(11, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(11, "challenge", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + manager + .complete_status_auth(11, &proof) + .await + .expect("test socket owns projection"); - #[tokio::test] - async fn matching_monotonic_expiry_clears_when_wall_clock_looks_early() { - let manager = WebSocketManager::default(); - let handle = test_handle(None); - let epoch = test_epoch(0x35); - let fresh_until = unix_now() + 60; - { - let mut projection = manager.projection.lock().await; - projection.owner = Some(ProjectionOwner { - id: 13, - handle: handle.clone(), - epoch: epoch.clone(), - attempt: 2, - presentation_token: 3, - channel: silent_channel(), - }); - projection.current = Some(CurrentProjection { - event_author_pubkey: "22".repeat(32), + let fresh_until = unix_now() + 2; + manager + .apply_projection_update( + 11, + &handle, + &epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: author.to_hex(), fresh_until, connection_epoch: epoch.as_str().to_owned(), - }); - } - - assert!(unix_now() < fresh_until, "test models a backward clock"); - manager - .expire_projection_if_owner(13, &handle, &epoch, 2, 3, fresh_until) - .await; - assert!(manager.projection.lock().await.current.is_none()); - } - - #[tokio::test] - async fn status_expiry_sleep_keeps_deadline_across_delayed_first_poll() { - let deadline = monotonic_deadline_after(Duration::from_millis(1)); - tokio::time::sleep(Duration::from_millis(10)).await; - - let sleep = status_expiry_sleep(deadline); - assert_eq!(sleep.deadline(), deadline); - sleep.await; - - let overflow_deadline = monotonic_deadline_after(Duration::MAX); - assert!(overflow_deadline <= tokio::time::Instant::now()); - } - - #[tokio::test] - async fn projection_expires_while_reserved_fold_is_blocked() { - let manager = WebSocketManager::default(); - let relay = nostr::Keys::generate().public_key(); - let author = nostr::Keys::generate().public_key(); - let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); - let epoch = test_epoch(0x44); - let pause = Arc::new(TestFoldPause::new()); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(Some(test_status_scope( - generation, - attempt, - relay, - author, - epoch.clone(), - ))), - fold_pause: Some(pause.clone()), - }); - manager.connections.lock().await.insert(11, handle.clone()); - manager - .record_status_challenge(11, &handle, "challenge") - .await; - let proof = manager - .status_auth_proof(11, "challenge", "ws://localhost:3000/", author) - .await - .expect("exact native proof"); - manager - .complete_status_auth(11, &proof) - .await - .expect("test socket owns projection"); - - let fresh_until = unix_now() + 2; - manager - .apply_projection_update( - 11, - &handle, - &epoch, - ProjectionUpdate::Current(CurrentProjection { - event_author_pubkey: author.to_hex(), - fresh_until, - connection_epoch: epoch.as_str().to_owned(), - }), - ) - .await; - assert!(manager.projection.lock().await.current.is_some()); - - let (client_io, server_io) = duplex(4096); - let (client, mut server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let task = tauri::async_runtime::spawn(run_connection_inner( - 11, - client, - receiver, - handle.cancel.clone(), - silent_channel(), - manager.clone(), - handle.clone(), - Some(ClientBindingStatusSession::new(relay, author, epoch)), - )); - *handle.task.lock().await = Some(task); - server - .send(Message::Text( - serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) - .to_string() - .into(), - )) - .await - .expect("send reserved frame"); + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_some()); + + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let task = tauri::async_runtime::spawn(run_connection_inner( + 11, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + handle.clone(), + Some(ClientBindingStatusSession::new(relay, author, epoch)), + )); + *handle.task.lock().await = Some(task); + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into(), + )) + .await + .expect("send reserved frame"); - let entered = tokio::time::timeout(Duration::from_secs(1), async { - while !pause.entered.load(Ordering::SeqCst) { - tokio::task::yield_now().await; + let entered = tokio::time::timeout(Duration::from_secs(1), async { + while !pause.entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .is_ok(); + let visible_after_entering_fold = manager.projection.lock().await.current.is_some(); + let expired = if entered { + tokio::time::timeout(Duration::from_secs(3), async { + while manager.projection.lock().await.current.is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; } }) .await - .is_ok(); - let visible_after_entering_fold = manager.projection.lock().await.current.is_some(); - let expired = if entered { - tokio::time::timeout(Duration::from_secs(3), async { - while manager.projection.lock().await.current.is_some() { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .is_ok() - } else { - false - }; - - pause.release(); - server - .send(Message::Close(None)) - .await - .expect("close synthetic socket"); - let task = handle.task.lock().await.take().expect("registered task"); - tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("connection loop exits") - .expect("connection task joins"); - - assert!(entered, "reserved fold should reach its blocking section"); - assert!( - visible_after_entering_fold, - "projection should still be visible when the fold blocks" - ); - assert!( - expired, - "deadline must clear while the fold remains blocked" - ); - assert!(unix_now() >= fresh_until); - } - - #[tokio::test] - async fn stale_task_cannot_remove_reused_connection_id() { - let manager = WebSocketManager::default(); - let (old_sender, _old_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let old = Arc::new(ConnectionHandle { - sender: old_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let current = Arc::new(ConnectionHandle { - sender: new_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(9, old.clone()); - manager.connections.lock().await.insert(9, current.clone()); - - manager.remove_if_current(9, &old).await; - assert!(manager - .connections - .lock() - .await - .get(&9) - .is_some_and(|handle| Arc::ptr_eq(handle, ¤t))); - manager.remove_if_current(9, ¤t).await; - assert!(!manager.connections.lock().await.contains_key(&9)); - } - - #[tokio::test] - async fn reserved_text_is_swallowed_but_binary_remains_raw_delivery() { - let manager = WebSocketManager::default(); - let delivered = Arc::new(AtomicUsize::new(0)); - let delivered_for_channel = delivered.clone(); - let channel = Channel::new(move |_: InvokeResponseBody| { - delivered_for_channel.fetch_add(1, Ordering::SeqCst); - Ok(()) - }); - let (client_io, server_io) = duplex(4096); - let (client, mut server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - status_scope: Mutex::new(None), - fold_pause: None, - }); - manager.connections.lock().await.insert(42, handle.clone()); - let task = tauri::async_runtime::spawn(run_connection( - 42, - client, - receiver, - handle.cancel.clone(), - channel, - manager.clone(), - )); - *handle.task.lock().await = Some(task); - - server - .send(Message::Text( - serde_json::json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]) - .to_string() - .into(), - )) - .await - .expect("send reserved bootstrap frame"); - server - .send(Message::Binary( - serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) - .to_string() - .into_bytes() - .into(), - )) - .await - .expect("send reserved status frame"); - server - .send(Message::Text("ordinary".into())) - .await - .expect("send ordinary frame"); - server - .send(Message::Close(None)) - .await - .expect("close synthetic socket"); - - let task = handle - .task - .lock() - .await - .take() - .expect("connection task is registered"); - tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("connection loop exits") - .expect("connection task joins"); - assert_eq!( - delivered.load(Ordering::SeqCst), - 3, - "binary, ordinary text, and terminal close reach raw delivery" - ); - assert!(!manager.connections.lock().await.contains_key(&42)); - } + .is_ok() + } else { + false + }; - #[test] - fn reserved_classifier_never_intercepts_binary() { - let reserved = - serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]).to_string(); - assert!(reserved_text_message(&Message::Text(reserved.clone().into())).is_some()); - assert!(reserved_text_message(&Message::Binary(reserved.into_bytes().into())).is_none()); - } + pause.release(); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + let task = handle.task.lock().await.take().expect("registered task"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + + assert!(entered, "reserved fold should reach its blocking section"); + assert!( + visible_after_entering_fold, + "projection should still be visible when the fold blocks" + ); + assert!( + expired, + "deadline must clear while the fold remains blocked" + ); + assert!(unix_now() >= fresh_until); +} + +#[tokio::test] +async fn stale_task_cannot_remove_reused_connection_id() { + let manager = WebSocketManager::default(); + let (old_sender, _old_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let old = Arc::new(ConnectionHandle { + sender: old_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let current = Arc::new(ConnectionHandle { + sender: new_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(9, old.clone()); + manager.connections.lock().await.insert(9, current.clone()); + + manager.remove_if_current(9, &old).await; + assert!(manager + .connections + .lock() + .await + .get(&9) + .is_some_and(|handle| Arc::ptr_eq(handle, ¤t))); + manager.remove_if_current(9, ¤t).await; + assert!(!manager.connections.lock().await.contains_key(&9)); +} + +#[tokio::test] +async fn reserved_text_is_swallowed_but_binary_remains_raw_delivery() { + let manager = WebSocketManager::default(); + let delivered = Arc::new(AtomicUsize::new(0)); + let delivered_for_channel = delivered.clone(); + let channel = Channel::new(move |_: InvokeResponseBody| { + delivered_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(42, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 42, + client, + receiver, + handle.cancel.clone(), + channel, + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]) + .to_string() + .into(), + )) + .await + .expect("send reserved bootstrap frame"); + server + .send(Message::Binary( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into_bytes() + .into(), + )) + .await + .expect("send reserved status frame"); + server + .send(Message::Text("ordinary".into())) + .await + .expect("send ordinary frame"); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); - #[test] - fn auth_challenge_recording_requires_exact_frame_shape() { - assert_eq!( - nip42_challenge(&serde_json::json!(["AUTH", "exact"]).to_string()).as_deref(), - Some("exact") - ); - assert!( - nip42_challenge(&serde_json::json!(["AUTH", "exact", "extra"]).to_string()).is_none() - ); - assert!(nip42_challenge("not-json").is_none()); - } + let task = handle + .task + .lock() + .await + .take() + .expect("connection task is registered"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + assert_eq!( + delivered.load(Ordering::SeqCst), + 3, + "binary, ordinary text, and terminal close reach raw delivery" + ); + assert!(!manager.connections.lock().await.contains_key(&42)); +} + +#[test] +fn reserved_classifier_never_intercepts_binary() { + let reserved = + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]).to_string(); + assert!(reserved_text_message(&Message::Text(reserved.clone().into())).is_some()); + assert!(reserved_text_message(&Message::Binary(reserved.into_bytes().into())).is_none()); +} + +#[test] +fn auth_challenge_recording_requires_exact_frame_shape() { + assert_eq!( + nip42_challenge(&serde_json::json!(["AUTH", "exact"]).to_string()).as_deref(), + Some("exact") + ); + assert!(nip42_challenge(&serde_json::json!(["AUTH", "exact", "extra"]).to_string()).is_none()); + assert!(nip42_challenge("not-json").is_none()); +} From b51984d1ccfb99398df46498d075a1d7574e0cf6 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:50:24 -0500 Subject: [PATCH 39/40] fix(desktop): satisfy Tauri clippy gate Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- desktop/src-tauri/src/client_binding_status_session.rs | 1 + desktop/src-tauri/src/native_websocket.rs | 4 ++-- desktop/src-tauri/tests/current_binding_status_native_flow.rs | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/client_binding_status_session.rs b/desktop/src-tauri/src/client_binding_status_session.rs index 8b913a3460..ba36b734cc 100644 --- a/desktop/src-tauri/src/client_binding_status_session.rs +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -100,6 +100,7 @@ impl ClientBindingStatusSession { }) } + #[cfg(test)] pub(crate) fn projected_fresh_until(&self) -> Option { self.projected_fresh_until } diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 485c60e677..69e2324ae8 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -321,7 +321,7 @@ impl WebSocketManager { if let Some((id, handle, epoch, attempt, presentation_token, fresh_until)) = expiry { let manager = self.clone(); let expires_at = monotonic_deadline_after(duration_until_unix_second(fresh_until)); - let _ = tauri::async_runtime::spawn(async move { + std::mem::drop(tauri::async_runtime::spawn(async move { status_expiry_sleep(expires_at).await; manager .expire_projection_if_owner( @@ -333,7 +333,7 @@ impl WebSocketManager { fresh_until, ) .await; - }); + })); } } diff --git a/desktop/src-tauri/tests/current_binding_status_native_flow.rs b/desktop/src-tauri/tests/current_binding_status_native_flow.rs index 3661ca4ef8..c3659bb2b1 100644 --- a/desktop/src-tauri/tests/current_binding_status_native_flow.rs +++ b/desktop/src-tauri/tests/current_binding_status_native_flow.rs @@ -46,6 +46,7 @@ mod egress_guard { } } +#[allow(dead_code)] mod native_websocket { include!("../src/native_websocket.rs"); From b042940f49f23439bfa836deb1065c3d853b3355 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:28:06 -0500 Subject: [PATCH 40/40] test(relay): align status conformance with desktop binding Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .../tests/nip_fi_runtime_conformance.rs | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs index 50f7c8c4f4..ce77d98c08 100644 --- a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -17,6 +17,8 @@ const READ_ONLY_RELAY_CLIENT: &str = include_str!("../../../desktop/src/shared/api/readOnlyRelayClient.ts"); const PRIMARY_RELAY_CLIENT: &str = include_str!("../../../desktop/src/shared/api/relayClientSession.ts"); +const STATUS_RELAY_CLIENT: &str = + include_str!("../../../desktop/src/shared/api/relayClientStatusConnection.ts"); const NATIVE_WEBSOCKET: &str = include_str!("../../../desktop/src-tauri/src/native_websocket.rs"); const DESKTOP_BUILD: &str = include_str!("../../../desktop/src-tauri/build.rs"); const WORKSPACE_COMMAND: &str = @@ -327,9 +329,18 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { continue; } let source = fs::read_to_string(&file).expect("source file is readable"); + let native_socket_tests = + file == repo.join("desktop/src-tauri/src/native_websocket_tests.rs"); + if native_socket_tests { + assert!(NATIVE_WEBSOCKET + .contains("#[cfg(test)]\n#[path = \"native_websocket_tests.rs\"]\nmod tests;")); + continue; + } let native_session = file == repo.join("desktop/src-tauri/src/client_binding_status_session.rs"); let native_socket = file == repo.join("desktop/src-tauri/src/native_websocket.rs"); + let native_socket_status = + file == repo.join("desktop/src-tauri/src/native_websocket_status.rs"); let native_module_declaration = file == repo.join("desktop/src-tauri/src/lib.rs"); if native_module_declaration { assert_eq!( @@ -350,12 +361,19 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { "24244", "deliver_verification_only", ] { - let expected_native_count = match (native_session, native_socket, forbidden) { - (true, false, "KIND_CLIENT_BINDING_STATUS") => Some(1), - (true, false, "ClientBindingStatus") => Some(33), - (true, false, "client_binding_status") => Some(2), - (false, true, "ClientBindingStatus") => Some(5), - (false, true, "client_binding_status") => Some(1), + let expected_native_count = match ( + native_session, + native_socket, + native_socket_status, + forbidden, + ) { + (true, false, false, "KIND_CLIENT_BINDING_STATUS") => Some(1), + (true, false, false, "ClientBindingStatus") => Some(33), + (true, false, false, "client_binding_status") => Some(2), + (false, true, false, "ClientBindingStatus") => Some(2), + (false, true, false, "client_binding_status") => Some(1), + (false, false, true, "ClientBindingStatus") => Some(3), + (false, false, true, "client_binding_status") => Some(1), _ => None, }; if let Some(expected) = expected_native_count { @@ -463,7 +481,7 @@ fn auth_bootstrap_precedes_status_and_success_ack() { } #[test] -fn native_status_connect_is_dedicated_and_primary_composition_remains_pending() { +fn native_status_connect_is_dedicated_and_primary_composition_is_bound() { let ordinary = NATIVE_WEBSOCKET .split("async fn connect(") .nth(1) @@ -481,12 +499,14 @@ fn native_status_connect_is_dedicated_and_primary_composition_remains_pending() assert!(NATIVE_WEBSOCKET.contains("connect_with_status,")); assert!(DESKTOP_BUILD.contains("\"connect_with_status\"")); - // J0/J2 composition remains HOLD until the primary session opts into the - // dedicated seam and passes its returned native ID through NIP-42 AUTH. - assert!(PRIMARY_RELAY_CLIENT.contains("plugin:websocket|connect")); - assert!(!PRIMARY_RELAY_CLIENT.contains("plugin:websocket|connect_with_status")); - assert!(!PRIMARY_RELAY_CLIENT.contains("onProjection")); - assert!(!PRIMARY_RELAY_CLIENT.contains("nativeWebsocketId")); + assert!(PRIMARY_RELAY_CLIENT.contains("RelayClientStatusConnection")); + assert!(PRIMARY_RELAY_CLIENT.contains("statusConnection.connect(")); + assert!(PRIMARY_RELAY_CLIENT.contains("statusConnection.bind(wsId, connectionRelayUrl);")); + assert!(PRIMARY_RELAY_CLIENT.contains("statusConnection.handleAuthChallenge(rest[0])")); + assert!(STATUS_RELAY_CLIENT.contains("\"plugin:websocket|connect_with_status\"")); + assert!(!STATUS_RELAY_CLIENT.contains("\"plugin:websocket|connect\"")); + assert!(STATUS_RELAY_CLIENT.contains("onProjection: this.projectionChannel")); + assert!(STATUS_RELAY_CLIENT.contains("nativeWebsocketId: binding.id")); } #[test]