diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index 41a5e017f1..c50a8e6d58 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -79,6 +79,18 @@ class QuicWebTransportFactory( * the Extended CONNECT request stream before giving up with HandshakeFailed. */ private val connectTimeoutMillis: Long = 10_000L, + /** + * WebTransport sub-protocols to advertise on the Extended CONNECT request, + * via the `wt-available-protocols` header (RFC 8941 Structured Field List + * of strings — see draft-ietf-webtrans-http3-14 §3.3). + * + * For nests, this MUST contain `moq-lite-03`; without it, moq-relay falls + * back to the legacy in-band SETUP exchange (moq-lite-02) and our first + * post-CONNECT message is decoded as SETUP_CLIENT, producing + * `connection closed err=invalid value` on the relay side and a stalled + * subscribe / `subscribe stream FIN before reply` on the client side. + */ + private val webTransportSubProtocols: List = listOf("moq-lite-03"), ) : WebTransportFactory { override suspend fun connect( authority: String, @@ -136,7 +148,13 @@ class QuicWebTransportFactory( // Open the Extended CONNECT request stream. val requestStream = conn.openBidiStream() - val headers = buildExtendedConnectHeaders(authority, path, bearerToken) + val extraHeaders = + if (webTransportSubProtocols.isNotEmpty()) { + listOf("wt-available-protocols" to encodeSfStringList(webTransportSubProtocols)) + } else { + emptyList() + } + val headers = buildExtendedConnectHeaders(authority, path, bearerToken, extraHeaders) requestStream.send.enqueue(encodeHeadersFrame(headers)) driver.wakeup() @@ -214,6 +232,21 @@ class QuicWebTransportFactory( val status: Int, ) : RuntimeException() + /** + * Encode [items] as an RFC 8941 Structured Field List of bare strings + * (the format `wt-available-protocols` requires per draft-ietf-webtrans-http3 + * §3.3). Each entry becomes `"value"`; the items are comma-separated. + * + * The values we emit (e.g. `moq-lite-03`) are bare ASCII so we don't + * need to handle escaping — assert it instead of silently mis-emitting. + */ + private fun encodeSfStringList(items: List): String { + require(items.all { p -> p.all { it in 0x20.toChar()..0x7e.toChar() && it != '"' && it != '\\' } }) { + "wt-available-protocols entry contains characters that need RFC 8941 escaping: $items" + } + return items.joinToString(", ") { "\"$it\"" } + } + private fun splitAuthority(authority: String): Pair { val idx = authority.lastIndexOf(':') if (idx <= 0) return authority to 443 diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt index 8957dd23ac..6cd356ce15 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -222,8 +222,14 @@ class NostrNestsHarness private constructor( private fun startExternal(): NostrNestsHarness { try { waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS) - waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS) waitForHealth("http://127.0.0.1:$AUTH_HOST_PORT/health", PORT_READY_TIMEOUT_MS) + // moq-relay is UDP only — the Docker compose forwarder + // happens to open TCP on 4443 too, which is what the + // Docker-mode probe relies on. Bare-metal moq-relay + // doesn't, so a TCP probe fails with ConnectException + // even though the UDP listener is healthy. Skip it + // here; the actual QUIC handshake from the test will + // surface a real connection problem if there is one. } catch (t: Throwable) { throw IllegalStateException( "external moq-auth / moq-relay not reachable on 127.0.0.1:" +