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
@@ -0,0 +1,110 @@
# Plan: bridge the moq-lite protocol gap
**Status:** discovered, not yet decided.
**Context:** discovered while writing the nostrnests interop test suite (phases 14).
## Discovery
`:nestsClient` implements **IETF draft-ietf-moq-transport-17** (`TrackNamespace`
tuples, `CLIENT_SETUP` / `SERVER_SETUP`, `OBJECT_DATAGRAM` with `track_alias`,
two-message ANNOUNCE / SUBSCRIBE shape, etc.).
The actual nostrnests stack is built on **moq-lite** — kixelated's own MoQ
flavour, wire-incompatible with IETF MoQ-transport. Reference points:
- JS client `NestsUI-v2/package.json` depends on `@moq/lite`, `@moq/publish`,
`@moq/watch`. None are IETF MoQ-transport.
- Rust relay `kixelated/moq-rs` is built on `rs/moq-lite/` types throughout
(see `rs/moq-lite/src/path.rs`, `rs/moq-lite/src/model/origin.rs`).
- ANNOUNCE wire on moq-lite: `(active: bool, suffix: string)` — single
string, no tuple. Source: `@moq/lite/lite/announce.js:14-19`.
- SUBSCRIBE wire on moq-lite:
`(id: u62, broadcast: string, track: string, priority: u8, …)` — two
independent strings. Source: `@moq/lite/lite/subscribe.js:87-91`.
- Path model: a plain string, `/`-joined and trim-normalised; prefix-strip
is delimiter-aware (`"foo"` does NOT match `"foobar"`).
Concrete shapes the JS reference sends per nests room:
| Wire field | JS reference value |
| ---------------------- | ---------------------------------------- |
| WT URL path | `/nests/30312:<host>:<roomId>` |
| `?jwt=` query | JWT (`claims.root` = the same path) |
| `claims.put` (publish) | `[<myPubkey>]` |
| `claims.get` | `[""]` |
| ANNOUNCE.suffix | `<myPubkey>` (single string) |
| SUBSCRIBE.broadcast | `<speakerPubkey>` (single string) |
| SUBSCRIBE.track | `"catalog.json"` then `"audio/data"` |
## Why this matters
The wire-shape fixes landed in this PR (path = `/<namespace>`, JWT in
`?jwt=` query) make the WebTransport CONNECT itself succeed against
moq-rs. But the FIRST MoQ control message we send afterwards
(IETF `ClientSetup`) is unintelligible to moq-rs's moq-lite framing, so
all post-CONNECT integration tests (round-trip, multi-peer, fan-out,
subscribe-before-announce) cannot pass against real nests in their
current form.
## Options
### A. Add moq-lite codec alongside the IETF one
- New `MoqLiteSession` parallel to `MoqSession`.
- New `NestsListener` / `NestsSpeaker` impls switched at construction
time, or behind a `MoqDialect` enum on `NestsRoomConfig`.
- ~12 weeks of work; two parallel protocols to maintain.
- Keeps the IETF unit-test suite intact for any future IETF target.
- **Pro:** real interop with nests; production speaker/listener works.
- **Con:** dual codepaths.
### B. Drop IETF MoQ-transport, replace with moq-lite
- Rewrites `MoqSession`, `MoqCodec`, `MoqMessage`, all unit tests.
- Smaller surface long-term.
- **Pro:** one truth; less code.
- **Con:** discards finished IETF work; risks rework if the audio-rooms
NIP later pivots to IETF MoQ.
### C. Hold integration round-trip / multi-peer tests, ship now
- This PR: phase-1 auth ping, phase-3 round-trip code, phase-4 wire fix,
phase-4 negative auth + endpoints, phase-4 multi-peer code.
- All test code that reaches into MoQ framing is `-DnestsInterop=true`
gated, so the default test run stays green.
- Land a TODO in this doc; pick A or B as a separate planned phase.
### D. Pivot the round-trip target to an IETF MoQ-transport server
- e.g. quic-go-moq, aioquic-moq, moq-go.
- Validates our IETF MoQ codec but does NOT validate nests interop —
different goalpost, same effort to wire up a Docker harness.
## Recommendation
**C now, A next phase.** Land the wire fixes + HTTP-only tests + the
multi-peer test code (gated) so we have the test scaffolding in place
when moq-lite framing lands. Treat moq-lite as an explicit phase-5
work item with its own design doc.
## Open questions
- Does moq-rs accept IETF MoQ-transport behind any flag? (Quick check
needed; agent's read of `rs/moq-lite/` suggests no.)
- Is the audio-rooms NIP draft IETF-MoQ-transport-binding, or
moq-lite-binding? If IETF, nostrnests is non-conforming and may
switch; if moq-lite, the NIP itself is moq-lite-bound and we should
pursue option A.
- ALPN check: WT itself uses `h3`; moq-lite framing rides on top of
WT bidi streams + datagrams, same as IETF MoQ-transport. The
`/anon` vs `/<namespace>` URL path mismatch is unrelated.
## When picking up
- Read `NestsUI-v2/src/transport/moq-transport.ts` and the `@moq/lite`
/ `@moq/publish` / `@moq/watch` packages cached at
`~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/`.
- Read `kixelated/moq-rs/rs/moq-lite/src/` (announce.rs, subscribe.rs,
path.rs, model/origin.rs) for the relay's view.
- The nests-side `claims.put = [pubkey]` rule is in
`moq-auth/src/index.ts:160-166`.
@@ -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
@@ -79,8 +79,12 @@ class NestsConnectTest {
assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion)
assertEquals("relay.example.com", transport.lastConnectedAuthority)
assertEquals("/moq", transport.lastConnectedPath)
assertEquals("tok-abc", transport.lastBearer)
assertEquals(
"/${room.moqNamespace()}?jwt=tok-abc",
transport.lastConnectedPath,
"moq-rs treats the WT path as the namespace literal and reads the JWT from `?jwt=`",
)
assertEquals(null, transport.lastBearer, "JWT goes in the query param, not Authorization")
assertEquals(false, httpClient.lastPublishFlag, "listener mints with publish=false")
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.interop
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Phase-4 ancillary endpoint smoke tests for the moq-auth sidecar. Cheap
* sanity checks for the routes we don't drive through the production
* client, so a regression in the sidecar's deployment (a port flip, a
* missing route, a busted CORS config) surfaces immediately rather than
* silently when the next /auth flow fires.
*
* Coverage:
* - `GET /health` returns `{"status":"ok"}` (used by Docker health
* probes; we read it for a test-time port-readiness check too)
* - `GET /.well-known/jwks.json` returns a JWK set with at least one
* ES256 verification key — moq-relay polls this every 30 s
* (`MOQ_AUTH_REFRESH_INTERVAL`) so a malformed JWKS would brick
* authentication
* - Unknown route returns 404 (locks down the surface)
*
* Skipped by default — set `-DnestsInterop=true` to enable.
*/
class NostrNestsAuthEndpointsInteropTest {
@Test
fun health_endpoint_returns_status_ok() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
http.newCall(Request.Builder().url("${harness.authBaseUrl}/health").build()).execute().use { resp ->
val body = resp.body.string()
assertEquals(200, resp.code, "/health should be 200, got ${resp.code}: $body")
assertTrue("\"status\"" in body && "\"ok\"" in body, "expected {\"status\":\"ok\"} JSON, got: $body")
}
}
@Test
fun jwks_endpoint_returns_at_least_one_es256_signing_key() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
http.newCall(Request.Builder().url("${harness.authBaseUrl}/.well-known/jwks.json").build()).execute().use { resp ->
val body = resp.body.string()
assertEquals(200, resp.code, "/.well-known/jwks.json should be 200, got ${resp.code}: $body")
// Don't pull a JWK parser into tests — substring sniffing is
// enough to assert the shape moq-relay actually consumes.
assertTrue("\"keys\"" in body, "JWKS body must contain a `keys` array, got: $body")
assertTrue(
"\"kty\"" in body,
"JWKS entry must have a `kty` (key type) field per RFC 7517, got: $body",
)
assertTrue(
"\"alg\":\"ES256\"" in body || "\"crv\":\"P-256\"" in body,
"expected ES256 / P-256 signing key (matches moq-rs claim verification), got: $body",
)
}
}
@Test
fun unknown_route_returns_404() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
http.newCall(Request.Builder().url("${harness.authBaseUrl}/does-not-exist").build()).execute().use { resp ->
assertEquals(404, resp.code, "unknown route should be 404")
}
}
companion object {
private val http = OkHttpClient()
private var harnessOrNull: NostrNestsHarness? = null
@BeforeClass
@JvmStatic
fun setUpHarness() {
if (NostrNestsHarness.isEnabled()) {
harnessOrNull = NostrNestsHarness.start()
}
}
@AfterClass
@JvmStatic
fun tearDownHarness() {
harnessOrNull?.close()
harnessOrNull = null
}
}
}
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.interop
import com.vitorpamplona.nestsclient.NestsAuth
import com.vitorpamplona.nestsclient.NestsException
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.nestsAuthUrl
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Phase-4 negative-auth interop tests. Locks in the wire-level rejection
* paths exposed by the moq-auth sidecar so a regression in our request
* shape (header format, NIP-98 signing, body schema, namespace regex)
* surfaces as a fast unit-of-work failure rather than a confused
* production stack trace.
*
* Mirrors the audit findings in moq-auth `index.ts` — rejection responses
* are JSON `{"error": "..."}` with HTTP 400 / 401 / 429 depending on the
* failure mode. We assert HTTP status only (the human-readable error text
* isn't part of the contract), but include the message in the failure
* message for debuggability.
*
* Skipped by default — set `-DnestsInterop=true` to enable.
*/
class NostrNestsAuthFailureInteropTest {
@Test
fun missing_authorization_header_is_rejected_401() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
val url = nestsAuthUrl(harness.authBaseUrl)
val body = """{"namespace":"${validNamespace()}","publish":false}"""
assertStatus(
expected = 401,
description = "missing Authorization header",
request =
Request
.Builder()
.url(url)
.post(body.toRequestBody(JSON))
.build(),
)
}
@Test
fun authorization_header_with_wrong_scheme_is_rejected_401() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
val url = nestsAuthUrl(harness.authBaseUrl)
val body = """{"namespace":"${validNamespace()}","publish":false}"""
assertStatus(
expected = 401,
description = "Authorization header with non-Nostr scheme",
request =
Request
.Builder()
.url(url)
.header("Authorization", "Bearer not-a-nostr-event")
.post(body.toRequestBody(JSON))
.build(),
)
}
@Test
fun nip98_event_signed_for_a_different_url_is_rejected_401() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
val signer = NostrSignerInternal(KeyPair())
val realUrl = nestsAuthUrl(harness.authBaseUrl)
val body = """{"namespace":"${validNamespace()}","publish":false}"""
val bodyBytes = body.encodeToByteArray()
// NIP-98 binds the signed event to a `u` tag that the server
// re-checks against the request URL. Sign for a *different*
// URL so the host/path mismatch fires.
val authHeader =
NestsAuth.header(
signer = signer,
url = "https://other.example.test/auth",
method = "POST",
payload = bodyBytes,
)
assertStatus(
expected = 401,
description = "NIP-98 event signed for the wrong URL",
request =
Request
.Builder()
.url(realUrl)
.header("Authorization", authHeader)
.post(body.toRequestBody(JSON))
.build(),
)
}
@Test
fun malformed_namespace_is_rejected_400() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
// Per moq-auth's regex /^nests\/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$/
// — uppercase pubkey hex doesn't match the `[0-9a-f]` class.
val signer = NostrSignerInternal(KeyPair())
val client = OkHttpNestsClient(http = http)
val room =
NestsRoomConfig(
authBaseUrl = harness.authBaseUrl,
endpoint = harness.moqEndpoint,
hostPubkey = "A".repeat(64),
roomId = "regex-fuzz",
)
try {
client.mintToken(room = room, publish = false, signer = signer)
fail("expected NestsException for invalid namespace, none thrown")
} catch (e: NestsException) {
assertEquals(
400,
e.status,
"expected 400 for namespace regex mismatch — got status=${e.status} message='${e.message}'",
)
}
}
@Test
fun publish_true_grants_a_token_for_any_caller() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
// The sidecar does NOT gate publish-vs-audience by NIP-53
// hostlist; any user requesting `publish: true` gets a JWT
// with `put: [<their-pubkey>]`. Lock that in so a future
// hostlist gate doesn't sneak in unnoticed.
val signer = NostrSignerInternal(KeyPair())
val client = OkHttpNestsClient(http = http)
val room =
NestsRoomConfig(
authBaseUrl = harness.authBaseUrl,
endpoint = harness.moqEndpoint,
hostPubkey = signer.pubKey,
roomId = "publish-grant-${System.currentTimeMillis()}",
)
val token = client.mintToken(room = room, publish = true, signer = signer)
assertTrue(token.count { it == '.' } == 2, "expected JWT with 3 segments, got: $token")
}
companion object {
private val JSON = "application/json".toMediaType()
private val http = OkHttpClient()
private var harnessOrNull: NostrNestsHarness? = null
private fun validNamespace(): String = "nests/30312:" + "0".repeat(64) + ":sample-room"
/**
* Issue [request] and assert the response status equals [expected].
* Reads the body once for the failure message — okhttp's `Response`
* body is single-shot, so we have to materialise the string before
* the `.use {}` block exits.
*/
private fun assertStatus(
expected: Int,
description: String,
request: Request,
) {
http.newCall(request).execute().use { response ->
val code = response.code
val bodyText = response.body.string()
assertEquals(
expected,
code,
"$description — server returned $code with body: $bodyText",
)
}
}
@BeforeClass
@JvmStatic
fun setUpHarness() {
if (NostrNestsHarness.isEnabled()) {
harnessOrNull = NostrNestsHarness.start()
}
}
@AfterClass
@JvmStatic
fun tearDownHarness() {
harnessOrNull?.close()
harnessOrNull = null
}
}
}
@@ -106,10 +106,12 @@ class NostrNestsHarness private constructor(
return NostrNestsHarness(
workDir = workDir,
authBaseUrl = "http://127.0.0.1:$AUTH_HOST_PORT",
// The moq-relay terminates TLS — we use a permissive
// certificate validator on the QuicWebTransportFactory in
// tests. Path is `/anon` per the relay's default WT route.
moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/anon",
// moq-rs terminates TLS with a self-signed cert in dev —
// production tests pair this with PermissiveCertificateValidator.
// The path under this base is the namespace literal (per
// moq-rs `claims.root` matching); the `:nestsClient` connect
// helpers append `/<namespace>?jwt=<token>` themselves.
moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/",
)
}
@@ -0,0 +1,406 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.interop
import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.NestsSpeaker
import com.vitorpamplona.nestsclient.NestsSpeakerState
import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.connectNestsSpeaker
import com.vitorpamplona.nestsclient.moq.MoqObject
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Phase-4 multi-peer interop tests. Each test brings up a real
* nostrnests stack, exercises a multi-publisher / multi-subscriber
* scenario through the production [connectNestsSpeaker] +
* [connectNestsListener] entry points, and asserts the relay's fan-out
* + ordering guarantees end-to-end.
*
* Coverage:
* - **Multi-listener fan-out** — 1 speaker, 2 listeners; each listener
* receives the full frame stream.
* - **Multi-speaker** — 2 speakers under the same room; one listener
* subscribes to each by pubkey hex; both subscriptions deliver
* their respective speaker's frames with no cross-talk.
* - **Subscribe-before-announce** — listener subscribes before the
* speaker has announced; per moq-rs the relay holds the subscribe
* and resolves it once a publisher arrives.
*
* Skipped by default — set `-DnestsInterop=true` to enable.
*/
class NostrNestsMultiPeerInteropTest {
@Test
fun one_speaker_fans_out_to_two_listeners() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
val room = freshRoom(harness, hostPubkey = "0".repeat(64))
val supervisor = SupervisorJob()
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
val speakerSigner = NostrSignerInternal(KeyPair())
val speakerRoom = room.copy(hostPubkey = speakerSigner.pubKey)
val capture = DriverCapture()
val listenerSignerA = NostrSignerInternal(KeyPair())
val listenerSignerB = NostrSignerInternal(KeyPair())
try {
val speaker =
connectSpeaker(
scope = pumpScope,
room = speakerRoom,
signer = speakerSigner,
capture = capture,
)
speaker.startBroadcasting()
val listenerA = connectListener(pumpScope, speakerRoom, listenerSignerA)
val listenerB = connectListener(pumpScope, speakerRoom, listenerSignerB)
val subA = listenerA.subscribeSpeaker(speakerSigner.pubKey)
val subB = listenerB.subscribeSpeaker(speakerSigner.pubKey)
val collectedA = collectFrames(pumpScope, subA, FRAMES_FANOUT)
val collectedB = collectFrames(pumpScope, subB, FRAMES_FANOUT)
delay(SUBSCRIBE_SETTLE_MS)
for (i in 0 until FRAMES_FANOUT) {
capture.push(shortArrayOf(i.toShort()))
delay(FRAME_SPACING_MS)
}
assertFrameSequence(collectedA.await(), FRAMES_FANOUT, "listener A")
assertFrameSequence(collectedB.await(), FRAMES_FANOUT, "listener B")
runCatching { subA.unsubscribe() }
runCatching { subB.unsubscribe() }
runCatching { listenerA.close() }
runCatching { listenerB.close() }
runCatching { speaker.close() }
} finally {
capture.stop()
supervisor.cancelAndJoin()
}
Unit
}
@Test
fun two_speakers_in_same_room_deliver_independently_to_one_listener() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
// Two speakers sharing the same room namespace must publish
// under different sub-paths (relay enforces `put: [pubkey]`).
// The room's hostPubkey gates `claims.root` — we use signer A's
// pubkey as the hostPubkey for the namespace, but each speaker
// mints their own JWT bound to their own pubkey via `put`.
val signerA = NostrSignerInternal(KeyPair())
val signerB = NostrSignerInternal(KeyPair())
val listenerSigner = NostrSignerInternal(KeyPair())
val room = freshRoom(harness, hostPubkey = signerA.pubKey)
val supervisor = SupervisorJob()
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
val captureA = DriverCapture()
val captureB = DriverCapture()
try {
val speakerA =
connectSpeaker(pumpScope, room, signerA, captureA, encoderPrefix = "A:")
val speakerB =
connectSpeaker(pumpScope, room, signerB, captureB, encoderPrefix = "B:")
speakerA.startBroadcasting()
speakerB.startBroadcasting()
val listener = connectListener(pumpScope, room, listenerSigner)
val subA = listener.subscribeSpeaker(signerA.pubKey)
val subB = listener.subscribeSpeaker(signerB.pubKey)
val collectedA = collectFrames(pumpScope, subA, FRAMES_MULTI_SPEAKER)
val collectedB = collectFrames(pumpScope, subB, FRAMES_MULTI_SPEAKER)
delay(SUBSCRIBE_SETTLE_MS)
for (i in 0 until FRAMES_MULTI_SPEAKER) {
captureA.push(shortArrayOf((i + 0).toShort()))
captureB.push(shortArrayOf((i + 100).toShort()))
delay(FRAME_SPACING_MS)
}
val resA = collectedA.await()
val resB = collectedB.await()
assertNotNull(resA, "listener never received speaker-A frames")
assertNotNull(resB, "listener never received speaker-B frames")
assertEquals(FRAMES_MULTI_SPEAKER, resA.size, "speaker A count")
assertEquals(FRAMES_MULTI_SPEAKER, resB.size, "speaker B count")
resA.forEachIndexed { idx, obj ->
assertContentEquals(
"A:".encodeToByteArray() + byteArrayOf(idx.toByte()),
obj.payload,
"speaker A frame $idx — must not contain B's payload (no cross-talk)",
)
}
resB.forEachIndexed { idx, obj ->
assertContentEquals(
"B:".encodeToByteArray() + byteArrayOf((idx + 100).toByte()),
obj.payload,
"speaker B frame $idx — must not contain A's payload (no cross-talk)",
)
}
runCatching { subA.unsubscribe() }
runCatching { subB.unsubscribe() }
runCatching { listener.close() }
runCatching { speakerA.close() }
runCatching { speakerB.close() }
} finally {
captureA.stop()
captureB.stop()
supervisor.cancelAndJoin()
}
Unit
}
@Test
fun listener_subscribed_before_announce_receives_late_frames() =
runBlocking {
NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking
// The relay holds the SUBSCRIBE open until a publisher
// announces. Validates moq-rs's "subscribe before announce"
// contract end-to-end through our client.
val speakerSigner = NostrSignerInternal(KeyPair())
val listenerSigner = NostrSignerInternal(KeyPair())
val room = freshRoom(harness, hostPubkey = speakerSigner.pubKey)
val supervisor = SupervisorJob()
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
val capture = DriverCapture()
try {
val listener = connectListener(pumpScope, room, listenerSigner)
val sub = listener.subscribeSpeaker(speakerSigner.pubKey)
val collected = collectFrames(pumpScope, sub, FRAMES_PRESUB)
// Wait briefly so the SUBSCRIBE is on the relay before
// the publisher arrives — the relay should hold it.
delay(SUBSCRIBE_SETTLE_MS)
val speaker = connectSpeaker(pumpScope, room, speakerSigner, capture)
speaker.startBroadcasting()
// Give the announce + publisher-side subscribe matchup
// time to plumb before pushing frames.
delay(SUBSCRIBE_SETTLE_MS)
for (i in 0 until FRAMES_PRESUB) {
capture.push(shortArrayOf(i.toShort()))
delay(FRAME_SPACING_MS)
}
assertFrameSequence(collected.await(), FRAMES_PRESUB, "pre-subscribed listener")
runCatching { sub.unsubscribe() }
runCatching { listener.close() }
runCatching { speaker.close() }
} finally {
capture.stop()
supervisor.cancelAndJoin()
}
Unit
}
// ---------- Helpers ----------
private suspend fun connectSpeaker(
scope: CoroutineScope,
room: NestsRoomConfig,
signer: NostrSignerInternal,
capture: AudioCapture,
encoderPrefix: String = "FRAME-",
): NestsSpeaker {
val speaker =
connectNestsSpeaker(
httpClient = http,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = signer.pubKey,
captureFactory = { capture },
encoderFactory = { StubEncoder(encoderPrefix.encodeToByteArray()) },
)
assertTrue(
speaker.state.value is NestsSpeakerState.Connected,
"speaker did not reach Connected — was ${speaker.state.value}",
)
return speaker
}
private suspend fun connectListener(
scope: CoroutineScope,
room: NestsRoomConfig,
signer: NostrSignerInternal,
): NestsListener {
val listener =
connectNestsListener(
httpClient = http,
transport = transport,
scope = scope,
room = room,
signer = signer,
)
assertTrue(
listener.state.value is NestsListenerState.Connected,
"listener did not reach Connected — was ${listener.state.value}",
)
return listener
}
private fun collectFrames(
scope: CoroutineScope,
sub: SubscribeHandle,
count: Int,
) = scope.async {
withTimeoutOrNull(RECEIVE_TIMEOUT_MS) {
sub.objects.take(count).toList()
}
}
private fun assertFrameSequence(
objs: List<MoqObject>?,
expectedCount: Int,
who: String,
) {
assertNotNull(objs, "$who did not receive $expectedCount frames within ${RECEIVE_TIMEOUT_MS}ms")
assertEquals(expectedCount, objs.size, "$who frame count")
objs.forEachIndexed { idx, obj ->
assertEquals(idx.toLong(), obj.objectId, "$who object id at index $idx")
assertContentEquals(
"FRAME-".encodeToByteArray() + byteArrayOf(idx.toByte()),
obj.payload,
"$who payload at index $idx",
)
}
}
private class DriverCapture : AudioCapture {
private val frames = Channel<ShortArray>(capacity = Channel.UNLIMITED)
@Volatile private var started: Boolean = false
override fun start() {
started = true
}
override suspend fun readFrame(): ShortArray? {
if (!started) return null
return frames.receiveCatching().getOrNull()
}
override fun stop() {
frames.close()
}
fun push(pcm: ShortArray) {
frames.trySend(pcm)
}
}
private class StubEncoder(
private val prefix: ByteArray,
) : OpusEncoder {
override fun encode(pcm: ShortArray): ByteArray = prefix + byteArrayOf(pcm.first().toByte())
override fun release() = Unit
}
companion object {
private const val FRAMES_FANOUT = 6
private const val FRAMES_MULTI_SPEAKER = 6
private const val FRAMES_PRESUB = 4
private const val SUBSCRIBE_SETTLE_MS = 500L
private const val FRAME_SPACING_MS = 25L
private const val RECEIVE_TIMEOUT_MS = 15_000L
private val http = OkHttpNestsClient()
private val transport =
QuicWebTransportFactory(certificateValidator = PermissiveCertificateValidator())
private var harnessOrNull: NostrNestsHarness? = null
private fun freshRoom(
harness: NostrNestsHarness,
hostPubkey: String,
) = NestsRoomConfig(
authBaseUrl = harness.authBaseUrl,
endpoint = harness.moqEndpoint,
hostPubkey = hostPubkey,
roomId = "mp-${System.nanoTime()}",
)
@BeforeClass
@JvmStatic
fun setUpHarness() {
if (NostrNestsHarness.isEnabled()) {
harnessOrNull = NostrNestsHarness.start()
}
}
@AfterClass
@JvmStatic
fun tearDownHarness() {
harnessOrNull?.close()
harnessOrNull = null
}
}
}