feat(qconnect): surface missing AUTHENTICATE JWT as a hard credential error

Add `require_jwt` (default false) to `WsTransportConfig` and a
`MissingAuthenticateJwt` error variant. When `require_jwt` is set but no
`jwt_qws` is present, the connect attempt now emits a `TransportError`,
closes the socket, and routes through the existing bounded reconnect path
instead of silently skipping the AUTHENTICATE frame (gap #12).

The default stays false so the InMemory / test transport path keeps
working without a JWT; `resolve_transport_config` flips it on for the real
Qobuz endpoint. Reuses the increment-only `handle_reconnect_delay`, so the
issue #358 latch is untouched.

cc
This commit is contained in:
vicrodh committed 2026-06-01 03:42:09 -06:00
1 parent 151e4cbd2d
commit 05d641d871
4 files changed
+52

No files matched your search

@@ -2,6 +2,11 @@
pub struct WsTransportConfig {
pub endpoint_url: String,
pub jwt_qws: Option<String>,
/// When `true`, a connect attempt with `jwt_qws == None` is a hard
/// credential error instead of silently skipping the AUTHENTICATE frame
/// (gap #12). Defaults to `false` so the InMemory / test transport path
/// keeps working without a JWT.
pub require_jwt: bool,
pub reconnect_backoff_ms: u64,
pub reconnect_backoff_max_ms: u64,
/// Maximum number of consecutive reconnect attempts before the transport
@@ -25,6 +30,7 @@ impl Default for WsTransportConfig {
Self {
endpoint_url: String::new(),
jwt_qws: None,
require_jwt: false,
reconnect_backoff_ms: 2_000,
reconnect_backoff_max_ms: 30_000,
reconnect_max_attempts: Some(10),
@@ -20,4 +20,6 @@ pub enum WsTransportError {
Serialization(String),
#[error("internal transport error: {0}")]
Internal(String),
#[error("missing AUTHENTICATE JWT for an endpoint that requires it")]
MissingAuthenticateJwt,
}
@@ -421,6 +421,38 @@ async fn run_native_transport_loop(
}
}
emit(&events_tx, TransportEvent::Authenticated);
} else if config.require_jwt {
// gap #12: the real Qobuz endpoint requires AUTHENTICATE. Silently
// skipping it leaves the session un-joinable; surface it as a hard
// credential error and route it through the existing bounded
// reconnect path (increment-only — issue #358 latch unaffected).
emit(
&events_tx,
TransportEvent::TransportError {
stage: "authenticate".to_string(),
message: "missing jwt_qws for endpoint that requires AUTHENTICATE".to_string(),
},
);
let _ = ws.close(None).await;
{
let mut guard = state.lock().await;
guard.connected = false;
}
emit(&events_tx, TransportEvent::Disconnected);
match handle_reconnect_delay(
&events_tx,
&mut shutdown_rx,
&mut reconnect_attempt,
&mut backoff,
max_backoff,
max_attempts,
"missing_authenticate_jwt".to_string(),
)
.await
{
ReconnectOutcome::Continue => continue,
ReconnectOutcome::Shutdown | ReconnectOutcome::Exhausted => break,
}
}
if config.auto_subscribe {
@@ -964,6 +996,15 @@ struct CloudPayload {
mod tests {
use super::*;
/// gap #12: `require_jwt` must default to `false` so the InMemory / test
/// transport path keeps working without a JWT. Only the real Qobuz endpoint
/// (set in `resolve_transport_config`) flips it on.
#[test]
fn config_require_jwt_defaults_false_for_test_path() {
let cfg = WsTransportConfig::default();
assert!(!cfg.require_jwt);
}
#[test]
fn qcloud_frame_roundtrip() {
let payload = vec![0x01, 0x02, 0x03, 0x04];