test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:
1. WebTransport CONNECT path is now /<moqNamespace> (matches the
relay's claims.root prefix check). Previously hardcoded "/anon".
2. JWT travels in the ?jwt=<token> query parameter, not the
Authorization header — moq-rs only reads the query param. The
bearer-token path on QuicWebTransportFactory is now unused for
nests; left in place for non-nests WebTransport servers.
3. Harness `moqEndpoint` is the relay base URL only; the connect
helpers append /<namespace>?jwt=<token> themselves.
Interop test additions (all -DnestsInterop=true gated, default-skipped):
- NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
for the wrong URL, malformed namespace per the strict regex,
publish=true grant for any caller — sidecar does NOT gate by NIP-53
hostlist).
- NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
shape (must contain ES256/P-256), 404 on unknown route.
- NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
speaker isolation, subscribe-before-announce. Code is wired through
production connectNestsSpeaker / connectNestsListener; will pass
once the moq-lite gap (below) is resolved.
Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
This commit is contained in:
@@ -80,7 +80,7 @@ suspend fun connectNestsListener(
|
||||
|
||||
val (authority, path) =
|
||||
try {
|
||||
parseEndpoint(room.endpoint)
|
||||
buildRelayConnectTarget(room.endpoint, room.moqNamespace(), token)
|
||||
} catch (t: Throwable) {
|
||||
state.value =
|
||||
NestsListenerState.Failed(
|
||||
@@ -92,7 +92,9 @@ suspend fun connectNestsListener(
|
||||
|
||||
val webTransport =
|
||||
try {
|
||||
transport.connect(authority = authority, path = path, bearerToken = token)
|
||||
// moq-rs reads the JWT from the `?jwt=` query parameter and
|
||||
// ignores the Authorization header — bearer must be null here.
|
||||
transport.connect(authority = authority, path = path, bearerToken = null)
|
||||
} catch (t: WebTransportException) {
|
||||
state.value =
|
||||
NestsListenerState.Failed(
|
||||
@@ -191,7 +193,7 @@ suspend fun connectNestsSpeaker(
|
||||
|
||||
val (authority, path) =
|
||||
try {
|
||||
parseEndpoint(room.endpoint)
|
||||
buildRelayConnectTarget(room.endpoint, room.moqNamespace(), token)
|
||||
} catch (t: Throwable) {
|
||||
state.value =
|
||||
NestsSpeakerState.Failed(
|
||||
@@ -203,7 +205,9 @@ suspend fun connectNestsSpeaker(
|
||||
|
||||
val webTransport =
|
||||
try {
|
||||
transport.connect(authority = authority, path = path, bearerToken = token)
|
||||
// moq-rs reads the JWT from the `?jwt=` query parameter and
|
||||
// ignores the Authorization header — bearer must be null here.
|
||||
transport.connect(authority = authority, path = path, bearerToken = null)
|
||||
} catch (t: WebTransportException) {
|
||||
state.value =
|
||||
NestsSpeakerState.Failed(
|
||||
@@ -258,6 +262,32 @@ private fun failedSpeaker(state: MutableStateFlow<NestsSpeakerState>): NestsSpea
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WebTransport connect target for a nests room.
|
||||
*
|
||||
* - **Authority** comes from the kind-30312 `endpoint` URL (host + port).
|
||||
* - **Path** is `/<moqNamespace>?jwt=<token>`. The JS reference client
|
||||
* overwrites `relayUrl.pathname` with the namespace literal — moq-rs
|
||||
* compares this path to `claims.root` for ANNOUNCE / SUBSCRIBE
|
||||
* authorisation, so any other path on the relay returns
|
||||
* `401 IncorrectRoot`.
|
||||
* - **Token** is delivered as the `?jwt=` query parameter (NOT an
|
||||
* `Authorization` header — moq-rs only reads the query param). Per the
|
||||
* ES256 JWT alphabet (base64url `[A-Za-z0-9_-]` + `.`) the token never
|
||||
* contains characters reserved in a query string, so no encoding is
|
||||
* applied. The path itself contains `:` and `/`, both legal in
|
||||
* `pchar` per RFC 3986.
|
||||
*/
|
||||
internal fun buildRelayConnectTarget(
|
||||
endpoint: String,
|
||||
namespace: String,
|
||||
token: String,
|
||||
): Pair<String, String> {
|
||||
val (authority, _) = parseEndpoint(endpoint)
|
||||
val path = "/" + namespace + "?jwt=" + token
|
||||
return authority to path
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a typical nests endpoint URL such as `https://relay.example.com/moq`
|
||||
* or `https://relay.example.com:4443/api/v1/moq?room=abc` into the
|
||||
|
||||
Reference in New Issue
Block a user