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:
Claude
2026-04-26 15:53:02 +00:00
parent 0ac8c0f791
commit 1887bd1fa7
7 changed files with 910 additions and 10 deletions
@@ -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