commit 85144e8d709a7d4729a9067d931386061fa73fb8
parent c8acf17e291877855fdf0301fb74f58165c64955
Author: triesap <tyson@radroots.org>
Date: Fri, 31 Jul 2026 19:16:30 +0000
nostr-connect: refactor the client state machine
- add a package-owned client, target, event, and relay-independent transport SPI
- model prepared, published, completed, timed-out, and cancelled request outcomes
- verify signer events, correlate responses, reject replays, and redact diagnostics
- cover happy, auth, timeout, cancellation, wrong-response, and duplicate paths
Diffstat:
5 files changed, 820 insertions(+), 5 deletions(-)
diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs
@@ -1,10 +1,459 @@
use crate::error::RadrootsNostrConnectError;
-use crate::message::{RPC_KIND, Request, RequestMessage, Response, ResponseEnvelope};
+use crate::message::{
+ RPC_KIND, Request, RequestId, RequestMessage, Response, ResponseEnvelope, ResponseValidator,
+};
use crate::method::Method;
+use crate::uri::{RELAY_COUNT_MAX, RelayUrl as ConnectRelayUrl};
use nostr::nips::nip44::{self, Version};
-use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag};
+use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag};
use std::future::Future;
use std::pin::Pin;
+use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+};
+
+pub const CLIENT_EVENT_MAX_BYTES: usize = 524_288;
+
+/// A relay-independent NIP-46 client with single-owner session key material.
+///
+/// The client owns request encryption, event signing, response selection, and
+/// protocol state. A host-provided [`Transport`] owns publication, waiting,
+/// timeout policy, and cancellation wakeups. Dropping an execution future after
+/// publication does not retract the remote request; use [`CancellationToken`]
+/// so the resulting [`CancellationPhase`] is observed explicitly.
+pub struct Client {
+ keys: Keys,
+ target: Target,
+ target_nostr_public_key: PublicKey,
+}
+
+impl Client {
+ /// Generates fresh single-owner client key material for this session.
+ pub fn generate(target: Target) -> Result<Self, RadrootsNostrConnectError> {
+ Self::from_keys(Keys::generate(), target)
+ }
+
+ /// Builds a client from a persisted hexadecimal or NIP-19 secret.
+ ///
+ /// Invalid inputs are normalized and never retained in diagnostics.
+ pub fn from_secret(secret: &str, target: Target) -> Result<Self, RadrootsNostrConnectError> {
+ let secret =
+ SecretKey::parse(secret).map_err(|_| RadrootsNostrConnectError::InvalidClientKey)?;
+ Self::from_keys(Keys::new(secret), target)
+ }
+
+ fn from_keys(keys: Keys, target: Target) -> Result<Self, RadrootsNostrConnectError> {
+ let target_nostr_public_key = radroots_nostr::key::public_key_to_nostr(
+ target.remote_signer_public_key,
+ )
+ .map_err(|_| RadrootsNostrConnectError::InvalidClientTarget {
+ reason: "remote signer public key is not a valid Nostr key",
+ })?;
+ Ok(Self {
+ keys,
+ target,
+ target_nostr_public_key,
+ })
+ }
+
+ /// Returns the public identity of this client session.
+ pub fn public_key(&self) -> Result<radroots_identity::PublicKey, RadrootsNostrConnectError> {
+ radroots_nostr::key::public_key_from_nostr(self.keys.public_key()).map_err(|_| {
+ RadrootsNostrConnectError::InvalidClientState {
+ reason: "client public key is invalid",
+ }
+ })
+ }
+
+ #[must_use]
+ pub fn target(&self) -> &Target {
+ &self.target
+ }
+
+ /// Constructs a validated, encrypted request in the prepared state.
+ pub fn prepare(
+ &self,
+ request_id: RequestId,
+ request: Request,
+ ) -> Result<Operation<'_>, RadrootsNostrConnectError> {
+ let method = request.method();
+ let message = RequestMessage::try_new(request_id.to_string(), request)?;
+ let event = build_request_event_for(&self.keys, self.target_nostr_public_key, message)?;
+ Ok(Operation {
+ client: self,
+ request_id: request_id.clone(),
+ method,
+ publication: ClientEvent(event),
+ phase: OperationPhase::Prepared,
+ validator: ResponseValidator::new(request_id, self.target.remote_signer_public_key),
+ })
+ }
+
+ /// Publishes and drives one request to response or explicit cancellation.
+ ///
+ /// Timeout policy belongs to `transport`, which reports [`Receive::TimedOut`].
+ /// The transport must observe `cancellation` while waiting and return
+ /// [`Receive::Cancelled`] promptly when cancellation wins its host-level
+ /// wait. Cancellation, progress-observer errors, or future drops after
+ /// publication stop local waiting without retracting signer-side work.
+ pub async fn execute<T, F>(
+ &self,
+ request_id: RequestId,
+ request: Request,
+ transport: &mut T,
+ cancellation: &CancellationToken,
+ mut on_progress: F,
+ ) -> Result<Completion, RadrootsNostrConnectError>
+ where
+ T: Transport,
+ F: FnMut(Progress) -> Result<(), RadrootsNostrConnectError>,
+ {
+ let mut operation = self.prepare(request_id, request)?;
+ if cancellation.is_cancelled() {
+ return operation.cancel().map(Completion::Cancelled);
+ }
+
+ transport.publish(operation.publication()?.clone()).await?;
+ operation.mark_published()?;
+ if cancellation.is_cancelled() {
+ return operation.cancel().map(Completion::Cancelled);
+ }
+
+ loop {
+ match transport.receive(cancellation).await? {
+ Receive::Event(event) => match operation.select(&event)? {
+ EventOutcome::Ignore => {}
+ EventOutcome::Progress(progress) => on_progress(progress)?,
+ EventOutcome::Complete(response) => {
+ return Ok(Completion::Response(response));
+ }
+ },
+ Receive::TimedOut => return Err(RadrootsNostrConnectError::RequestTimedOut),
+ Receive::Cancelled => return operation.cancel().map(Completion::Cancelled),
+ }
+ }
+ }
+}
+
+impl std::fmt::Debug for Client {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("Client")
+ .field("key_material", &"<redacted>")
+ .field("target", &self.target)
+ .finish()
+ }
+}
+
+/// A validated remote-signer target independent of relay implementation.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Target {
+ remote_signer_public_key: radroots_identity::PublicKey,
+ relays: Vec<ConnectRelayUrl>,
+}
+
+impl Target {
+ pub fn try_new(
+ remote_signer_public_key: radroots_identity::PublicKey,
+ relays: Vec<ConnectRelayUrl>,
+ ) -> Result<Self, RadrootsNostrConnectError> {
+ if relays.len() > RELAY_COUNT_MAX {
+ return Err(RadrootsNostrConnectError::InvalidClientTarget {
+ reason: "relay count exceeds its limit",
+ });
+ }
+ let mut normalized = Vec::with_capacity(relays.len());
+ for relay in relays {
+ if !normalized.contains(&relay) {
+ normalized.push(relay);
+ }
+ }
+ Ok(Self {
+ remote_signer_public_key,
+ relays: normalized,
+ })
+ }
+
+ #[must_use]
+ pub const fn remote_signer_public_key(&self) -> radroots_identity::PublicKey {
+ self.remote_signer_public_key
+ }
+
+ #[must_use]
+ pub fn relays(&self) -> &[ConnectRelayUrl] {
+ &self.relays
+ }
+}
+
+/// A signed NIP-46 protocol event with a package-owned representation.
+#[derive(Clone, PartialEq, Eq)]
+pub struct ClientEvent(Event);
+
+impl ClientEvent {
+ pub fn from_json(value: &str) -> Result<Self, RadrootsNostrConnectError> {
+ if value.len() > CLIENT_EVENT_MAX_BYTES {
+ return Err(RadrootsNostrConnectError::InvalidClientEvent);
+ }
+ Event::from_json(value)
+ .map(Self)
+ .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent)
+ }
+
+ #[must_use]
+ pub fn as_json(&self) -> String {
+ self.0.as_json()
+ }
+}
+
+impl std::fmt::Debug for ClientEvent {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str("ClientEvent(<redacted>)")
+ }
+}
+
+#[derive(PartialEq, Eq)]
+pub enum Progress {
+ AuthChallenge { url: String },
+}
+
+impl std::fmt::Debug for Progress {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str("Progress::AuthChallenge(<redacted>)")
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum EventOutcome {
+ Ignore,
+ Progress(Progress),
+ Complete(Box<Response>),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Completion {
+ Response(Box<Response>),
+ Cancelled(CancellationPhase),
+}
+
+impl Completion {
+ #[must_use]
+ pub fn response(response: Response) -> Self {
+ Self::Response(Box::new(response))
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CancellationPhase {
+ BeforePublication,
+ AfterPublication,
+}
+
+/// A cloneable host cancellation signal containing no runtime dependency.
+#[derive(Debug, Clone, Default)]
+pub struct CancellationToken(Arc<AtomicBool>);
+
+impl CancellationToken {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn cancel(&self) {
+ self.0.store(true, Ordering::Release);
+ }
+
+ #[must_use]
+ pub fn is_cancelled(&self) -> bool {
+ self.0.load(Ordering::Acquire)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Receive {
+ Event(Box<ClientEvent>),
+ TimedOut,
+ Cancelled,
+}
+
+impl Receive {
+ #[must_use]
+ pub fn event(event: ClientEvent) -> Self {
+ Self::Event(Box::new(event))
+ }
+}
+
+pub type TransportFuture<'a, T> =
+ Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>;
+
+/// Host SPI for publishing and receiving package-owned NIP-46 events.
+///
+/// External implementations are supported. The trait is dyn-compatible,
+/// `Send`, and returns `Send` futures. Implementations normalize backend
+/// failures into [`RadrootsNostrConnectError::Transport`]. The host owns
+/// deadlines and reports them as [`Receive::TimedOut`]. A successful `publish`
+/// may durably expose the request to a remote signer. Once that call returns,
+/// cancellation only stops local waiting and cannot retract remote work.
+/// `receive` must observe its token and return [`Receive::Cancelled`] when host
+/// cancellation wins.
+pub trait Transport: Send {
+ fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()>;
+
+ fn receive<'a>(
+ &'a mut self,
+ cancellation: &'a CancellationToken,
+ ) -> TransportFuture<'a, Receive>;
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum OperationPhase {
+ Prepared,
+ Published,
+ Completed,
+ Cancelled,
+}
+
+/// One request's explicit prepared/published/completed state machine.
+pub struct Operation<'a> {
+ client: &'a Client,
+ request_id: RequestId,
+ method: Method,
+ publication: ClientEvent,
+ phase: OperationPhase,
+ validator: ResponseValidator,
+}
+
+impl Operation<'_> {
+ pub fn publication(&self) -> Result<&ClientEvent, RadrootsNostrConnectError> {
+ if self.phase != OperationPhase::Prepared {
+ return Err(RadrootsNostrConnectError::InvalidClientState {
+ reason: "publication is only available while prepared",
+ });
+ }
+ Ok(&self.publication)
+ }
+
+ pub fn mark_published(&mut self) -> Result<(), RadrootsNostrConnectError> {
+ if self.phase != OperationPhase::Prepared {
+ return Err(RadrootsNostrConnectError::InvalidClientState {
+ reason: "only a prepared request can be marked published",
+ });
+ }
+ self.phase = OperationPhase::Published;
+ Ok(())
+ }
+
+ pub fn cancel(&mut self) -> Result<CancellationPhase, RadrootsNostrConnectError> {
+ let cancellation = match self.phase {
+ OperationPhase::Prepared => CancellationPhase::BeforePublication,
+ OperationPhase::Published => CancellationPhase::AfterPublication,
+ OperationPhase::Completed => {
+ return Err(RadrootsNostrConnectError::InvalidClientState {
+ reason: "completed request cannot be cancelled",
+ });
+ }
+ OperationPhase::Cancelled => {
+ return Err(RadrootsNostrConnectError::InvalidClientState {
+ reason: "request is already cancelled",
+ });
+ }
+ };
+ self.phase = OperationPhase::Cancelled;
+ Ok(cancellation)
+ }
+
+ pub fn select(
+ &mut self,
+ event: &ClientEvent,
+ ) -> Result<EventOutcome, RadrootsNostrConnectError> {
+ if matches!(
+ self.phase,
+ OperationPhase::Prepared | OperationPhase::Cancelled
+ ) {
+ return Err(RadrootsNostrConnectError::InvalidClientState {
+ reason: "responses require a published active request",
+ });
+ }
+
+ let event = &event.0;
+ if event.kind != Kind::Custom(RPC_KIND)
+ || event.pubkey != self.client.target_nostr_public_key
+ || !event
+ .tags
+ .public_keys()
+ .any(|public_key| *public_key == self.client.keys.public_key())
+ {
+ return Ok(EventOutcome::Ignore);
+ }
+ event
+ .verify()
+ .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent)?;
+
+ let decrypted = nip44::decrypt(
+ self.client.keys.secret_key(),
+ &self.client.target_nostr_public_key,
+ &event.content,
+ )
+ .map_err(|error| RadrootsNostrConnectError::Decrypt {
+ reason: error.to_string(),
+ })?;
+ let envelope: ResponseEnvelope =
+ serde_json::from_str(&decrypted).map_err(RadrootsNostrConnectError::from)?;
+ if envelope.request_id()? != self.request_id {
+ return Ok(EventOutcome::Ignore);
+ }
+ self.validator.validate(
+ self.client.target.remote_signer_public_key,
+ event.id.to_hex(),
+ &envelope,
+ )?;
+ if self.phase == OperationPhase::Completed {
+ return Err(RadrootsNostrConnectError::InvalidClientState {
+ reason: "request already completed",
+ });
+ }
+
+ match Response::from_envelope(&self.method, envelope)? {
+ Response::AuthUrl(url) => Ok(EventOutcome::Progress(Progress::AuthChallenge { url })),
+ response => {
+ self.phase = OperationPhase::Completed;
+ Ok(EventOutcome::Complete(Box::new(response)))
+ }
+ }
+ }
+}
+
+impl std::fmt::Debug for Operation<'_> {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("Operation")
+ .field("request_id", &self.request_id)
+ .field("method", &self.method)
+ .field("phase", &self.phase)
+ .field("publication", &"<redacted>")
+ .finish()
+ }
+}
+
+fn build_request_event_for(
+ client_keys: &Keys,
+ remote_signer_public_key: PublicKey,
+ message: RequestMessage,
+) -> Result<Event, RadrootsNostrConnectError> {
+ let payload = serde_json::to_string(&message).map_err(RadrootsNostrConnectError::from)?;
+ let ciphertext = nip44::encrypt(
+ client_keys.secret_key(),
+ &remote_signer_public_key,
+ payload,
+ Version::V2,
+ )
+ .map_err(encrypt_error)?;
+
+ EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext)
+ .tag(Tag::public_key(remote_signer_public_key))
+ .sign_with_keys(client_keys)
+ .map_err(sign_error)
+}
pub type RadrootsNostrConnectClientTransportFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>;
@@ -113,6 +562,9 @@ pub fn parse_response_event(
{
return Ok(RadrootsNostrConnectClientEventOutcome::Ignore);
}
+ event
+ .verify()
+ .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent)?;
let decrypted = nip44::decrypt(
client_keys.secret_key(),
diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs
@@ -12,6 +12,14 @@ pub enum RadrootsNostrConnectError {
Transport { reason: String },
#[error("NIP-46 request timed out")]
RequestTimedOut,
+ #[error("invalid NIP-46 client key")]
+ InvalidClientKey,
+ #[error("invalid NIP-46 client target: {reason}")]
+ InvalidClientTarget { reason: &'static str },
+ #[error("invalid NIP-46 client event")]
+ InvalidClientEvent,
+ #[error("invalid NIP-46 client state: {reason}")]
+ InvalidClientState { reason: &'static str },
#[error("invalid NIP-46 request id: {reason}")]
InvalidRequestId { reason: &'static str },
#[error("NIP-46 response id does not match the request")]
diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs
@@ -9,6 +9,7 @@ pub mod permission;
pub mod server;
pub mod uri;
+pub use client::Client;
pub use error::RadrootsNostrConnectError as Error;
pub use message::{Request, Response};
pub use method::Method;
@@ -20,9 +21,11 @@ pub use uri::{BunkerUri, ClientUri};
#[doc(hidden)]
pub mod prelude {
pub use crate::client::{
- RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectClientProgress,
- RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget,
- RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture,
+ CancellationPhase, CancellationToken, Client, ClientEvent, Completion, EventOutcome,
+ Operation, Progress, RadrootsNostrConnectClientEventOutcome,
+ RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest,
+ RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport,
+ RadrootsNostrConnectClientTransportFuture, Receive, Target, Transport, TransportFuture,
build_request_event, execute_request_with_transport, parse_response_event,
};
pub use crate::error::RadrootsNostrConnectError;
diff --git a/crates/nostr_connect/tests/client_state_machine.rs b/crates/nostr_connect/tests/client_state_machine.rs
@@ -0,0 +1,327 @@
+#[path = "../src/test_fixtures.rs"]
+mod test_fixtures;
+
+use nostr::nips::nip44::{self, Version};
+use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag};
+use radroots_nostr_connect::client::{
+ CLIENT_EVENT_MAX_BYTES, CancellationPhase, CancellationToken, ClientEvent, Completion,
+ EventOutcome, Progress, Receive, Target, Transport, TransportFuture,
+};
+use radroots_nostr_connect::message::{RPC_KIND, RequestId};
+use radroots_nostr_connect::uri::RelayUrl;
+use radroots_nostr_connect::{Client, Error, Request, Response};
+use std::collections::VecDeque;
+use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, RELAY_PRIMARY_WSS};
+
+fn keys(secret_key_hex: &str) -> Keys {
+ Keys::new(SecretKey::from_hex(secret_key_hex).expect("secret key"))
+}
+
+fn identity_public_key(public_key: PublicKey) -> radroots_identity::PublicKey {
+ radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key")
+}
+
+fn client(remote_keys: &Keys) -> Client {
+ Client::from_secret(
+ FIXTURE_ALICE.secret_key_hex,
+ Target::try_new(
+ identity_public_key(remote_keys.public_key()),
+ vec![RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")],
+ )
+ .expect("target"),
+ )
+ .expect("client")
+}
+
+fn response_event(
+ remote_keys: &Keys,
+ client_public_key: radroots_identity::PublicKey,
+ request_id: &str,
+ response: Response,
+) -> ClientEvent {
+ let client_public_key =
+ radroots_nostr::key::public_key_to_nostr(client_public_key).expect("client public key");
+ let envelope = response
+ .into_envelope(request_id)
+ .expect("response envelope");
+ let payload = serde_json::to_string(&envelope).expect("response JSON");
+ let ciphertext = nip44::encrypt(
+ remote_keys.secret_key(),
+ &client_public_key,
+ payload,
+ Version::V2,
+ )
+ .expect("response encryption");
+ let event = EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext)
+ .tag(Tag::public_key(client_public_key))
+ .sign_with_keys(remote_keys)
+ .expect("response event");
+ ClientEvent::from_json(&serde_json::to_string(&event).expect("event JSON"))
+ .expect("client event")
+}
+
+struct MockTransport {
+ published: Vec<ClientEvent>,
+ incoming: VecDeque<Receive>,
+ cancel_on_publish: Option<CancellationToken>,
+}
+
+impl MockTransport {
+ fn new(incoming: impl IntoIterator<Item = Receive>) -> Self {
+ Self {
+ published: Vec::new(),
+ incoming: incoming.into_iter().collect(),
+ cancel_on_publish: None,
+ }
+ }
+
+ fn cancelling_on_publish(token: CancellationToken) -> Self {
+ Self {
+ published: Vec::new(),
+ incoming: VecDeque::new(),
+ cancel_on_publish: Some(token),
+ }
+ }
+}
+
+impl Transport for MockTransport {
+ fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()> {
+ self.published.push(event);
+ if let Some(token) = self.cancel_on_publish.as_ref() {
+ token.cancel();
+ }
+ Box::pin(async { Ok(()) })
+ }
+
+ fn receive<'a>(
+ &'a mut self,
+ cancellation: &'a CancellationToken,
+ ) -> TransportFuture<'a, Receive> {
+ let outcome = if cancellation.is_cancelled() {
+ Receive::Cancelled
+ } else {
+ self.incoming.pop_front().unwrap_or(Receive::TimedOut)
+ };
+ Box::pin(async move { Ok(outcome) })
+ }
+}
+
+#[tokio::test]
+async fn client_completes_happy_path_after_one_publication() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ let client = client(&remote_keys);
+ let response = response_event(
+ &remote_keys,
+ client.public_key().expect("client public key"),
+ "request-happy",
+ Response::Pong,
+ );
+ let mut transport = MockTransport::new([Receive::event(response)]);
+
+ let completion = client
+ .execute(
+ RequestId::parse("request-happy").expect("request id"),
+ Request::Ping,
+ &mut transport,
+ &CancellationToken::new(),
+ |_| Ok(()),
+ )
+ .await
+ .expect("completion");
+
+ assert_eq!(completion, Completion::response(Response::Pong));
+ assert_eq!(transport.published.len(), 1);
+ assert!(!transport.published[0].as_json().contains("request-happy"));
+}
+
+#[tokio::test]
+async fn client_reports_auth_progress_before_completion() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ let client = client(&remote_keys);
+ let client_public_key = client.public_key().expect("client public key");
+ let mut transport = MockTransport::new([
+ Receive::event(response_event(
+ &remote_keys,
+ client_public_key,
+ "request-auth",
+ Response::AuthUrl("https://auth.example.test/challenge".to_owned()),
+ )),
+ Receive::event(response_event(
+ &remote_keys,
+ client_public_key,
+ "request-auth",
+ Response::Pong,
+ )),
+ ]);
+ let mut progress = Vec::new();
+
+ let completion = client
+ .execute(
+ RequestId::parse("request-auth").expect("request id"),
+ Request::Ping,
+ &mut transport,
+ &CancellationToken::new(),
+ |event| {
+ progress.push(event);
+ Ok(())
+ },
+ )
+ .await
+ .expect("completion");
+
+ assert_eq!(completion, Completion::response(Response::Pong));
+ assert_eq!(
+ progress,
+ [Progress::AuthChallenge {
+ url: "https://auth.example.test/challenge".to_owned(),
+ }]
+ );
+}
+
+#[tokio::test]
+async fn transport_owns_timeout_handoff() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ let client = client(&remote_keys);
+ let mut transport = MockTransport::new([Receive::TimedOut]);
+
+ let error = client
+ .execute(
+ RequestId::parse("request-timeout").expect("request id"),
+ Request::Ping,
+ &mut transport,
+ &CancellationToken::new(),
+ |_| Ok(()),
+ )
+ .await
+ .expect_err("timeout");
+
+ assert_eq!(error, Error::RequestTimedOut);
+ assert_eq!(transport.published.len(), 1);
+}
+
+#[tokio::test]
+async fn cancellation_distinguishes_before_and_after_publication() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ let client = client(&remote_keys);
+
+ let before = CancellationToken::new();
+ before.cancel();
+ let mut before_transport = MockTransport::new([]);
+ assert_eq!(
+ client
+ .execute(
+ RequestId::parse("request-cancel-before").expect("request id"),
+ Request::Ping,
+ &mut before_transport,
+ &before,
+ |_| Ok(()),
+ )
+ .await
+ .expect("before-publication cancellation"),
+ Completion::Cancelled(CancellationPhase::BeforePublication)
+ );
+ assert!(before_transport.published.is_empty());
+
+ let after = CancellationToken::new();
+ let mut after_transport = MockTransport::cancelling_on_publish(after.clone());
+ assert_eq!(
+ client
+ .execute(
+ RequestId::parse("request-cancel-after").expect("request id"),
+ Request::Ping,
+ &mut after_transport,
+ &after,
+ |_| Ok(()),
+ )
+ .await
+ .expect("after-publication cancellation"),
+ Completion::Cancelled(CancellationPhase::AfterPublication)
+ );
+ assert_eq!(after_transport.published.len(), 1);
+}
+
+#[test]
+fn state_machine_ignores_wrong_response_then_completes() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ let client = client(&remote_keys);
+ let client_public_key = client.public_key().expect("client public key");
+ let mut operation = client
+ .prepare(
+ RequestId::parse("request-select").expect("request id"),
+ Request::Ping,
+ )
+ .expect("operation");
+ operation.mark_published().expect("published");
+
+ let wrong = response_event(
+ &remote_keys,
+ client_public_key,
+ "other-request",
+ Response::Pong,
+ );
+ assert_eq!(
+ operation.select(&wrong).expect("wrong response"),
+ EventOutcome::Ignore
+ );
+ let expected = response_event(
+ &remote_keys,
+ client_public_key,
+ "request-select",
+ Response::Pong,
+ );
+ assert_eq!(
+ operation.select(&expected).expect("expected response"),
+ EventOutcome::Complete(Box::new(Response::Pong))
+ );
+}
+
+#[test]
+fn state_machine_rejects_duplicate_response_event() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ let client = client(&remote_keys);
+ let response = response_event(
+ &remote_keys,
+ client.public_key().expect("client public key"),
+ "request-replay",
+ Response::Pong,
+ );
+ let mut operation = client
+ .prepare(
+ RequestId::parse("request-replay").expect("request id"),
+ Request::Ping,
+ )
+ .expect("operation");
+ operation.mark_published().expect("published");
+ assert_eq!(
+ operation.select(&response).expect("first response"),
+ EventOutcome::Complete(Box::new(Response::Pong))
+ );
+ assert_eq!(
+ operation.select(&response).expect_err("duplicate response"),
+ Error::ReplayedResponse
+ );
+}
+
+#[test]
+fn client_key_and_event_diagnostics_are_redacted() {
+ let remote_keys = keys(FIXTURE_BOB.secret_key_hex);
+ assert_eq!(
+ Client::from_secret("not-a-secret", client(&remote_keys).target().clone())
+ .expect_err("invalid key"),
+ Error::InvalidClientKey
+ );
+ assert!(!format!("{:?}", client(&remote_keys)).contains(FIXTURE_ALICE.secret_key_hex));
+ assert_eq!(
+ ClientEvent::from_json("not an event").expect_err("invalid event"),
+ Error::InvalidClientEvent
+ );
+ assert_eq!(
+ ClientEvent::from_json(&"x".repeat(CLIENT_EVENT_MAX_BYTES + 1))
+ .expect_err("oversized event"),
+ Error::InvalidClientEvent
+ );
+ let progress = Progress::AuthChallenge {
+ url: "https://auth.example.test/?token=do-not-log".to_owned(),
+ };
+ assert!(!format!("{progress:?}").contains("do-not-log"));
+}
diff --git a/crates/nostr_connect/tests/package_boundary.rs b/crates/nostr_connect/tests/package_boundary.rs
@@ -1,6 +1,7 @@
use std::collections::BTreeSet;
const MANIFEST: &str = include_str!("../Cargo.toml");
+const CLIENT: &str = include_str!("../src/client.rs");
const METHOD: &str = include_str!("../src/method.rs");
const PERMISSION: &str = include_str!("../src/permission.rs");
const ROOT: &str = include_str!("../src/lib.rs");
@@ -98,6 +99,30 @@ fn uri_method_and_permission_types_use_canonical_owners_and_names() {
assert!(URI.contains("radroots_nostr::key::parse_public_key"));
}
+#[test]
+fn client_root_and_transport_use_package_owned_state_machine_types() {
+ assert!(ROOT.contains("pub use client::Client;"));
+ for required in [
+ "pub struct Client {",
+ "pub struct ClientEvent(Event);",
+ "pub struct Target {",
+ "pub trait Transport: Send {",
+ "pub enum CancellationPhase {",
+ ] {
+ assert!(CLIENT.contains(required), "client is missing `{required}`");
+ }
+ for forbidden in [
+ "pub struct Client {\n pub ",
+ "pub struct ClientEvent(pub ",
+ "pub struct Target {\n pub ",
+ ] {
+ assert!(
+ !CLIENT.contains(forbidden),
+ "client exposes representation through `{forbidden}`"
+ );
+ }
+}
+
fn table_keys<'a>(source: &'a str, header: &str) -> BTreeSet<&'a str> {
let Some((_, tail)) = source.split_once(header) else {
panic!("manifest is missing {header}");