refactor(audio-rooms): NestsClient API matches nostrnests reality (phase 2/3)

The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.

| Aspect    | Before                                    | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method    | GET                                       | POST                                             |
| URL       | `<base>/<roomId>`                         | `<base>/auth`                                    |
| Body      | none                                      | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response  | `{endpoint, token, codec, sample_rate}`   | `{token}` only                                   |
| Endpoint  | from response                             | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98    | bound to GET URL                          | bound to POST URL + body hash                    |

Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
  hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
  NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
  moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
  matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
  → `NestsClient.mintToken(room, publish, signer): String`. The
  publish flag drives the JWT claims (`get` for listeners, `put`
  for speakers).

Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
  and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
  NestsRoomConfig` instead of split (serviceBase, roomId), pass
  `publish=false` / `publish=true` respectively, and use the room's
  `endpoint` (not a server-returned one) for the WebTransport
  connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
  `Broadcasting` carry the `room: NestsRoomConfig` instead of the old
  `roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
  bytes are `room.moqNamespace()` — the simplest mapping to the
  relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
  test will confirm and adjust if the relay expects a multi-segment
  tuple.

Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
  with `(room: NestsRoomConfig)`. Connector seam interfaces
  (NestsListenerConnector, NestsSpeakerConnector) follow the same
  shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
  `EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
  + roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
  `EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
  `event.kind` in addition to `event.service()`; rooms missing any
  of those are silently un-joinable (the event author didn't host
  on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
  of (serviceBase, roomId) and threads it down.

Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.

Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.

Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
This commit is contained in:
Claude
2026-04-26 14:42:26 +00:00
parent 3283d302fa
commit beec8204e5
17 changed files with 353 additions and 318 deletions
@@ -98,9 +98,18 @@ class AudioRoomActivity : AppCompatActivity() {
val accountViewModel = AudioRoomBridge.accountViewModel val accountViewModel = AudioRoomBridge.accountViewModel
val addressValue = intent.getStringExtra(EXTRA_ADDRESS) val addressValue = intent.getStringExtra(EXTRA_ADDRESS)
val serviceBase = intent.getStringExtra(EXTRA_SERVICE_BASE) val authBaseUrl = intent.getStringExtra(EXTRA_AUTH_BASE_URL)
val endpoint = intent.getStringExtra(EXTRA_ENDPOINT)
val hostPubkey = intent.getStringExtra(EXTRA_HOST_PUBKEY)
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) val roomId = intent.getStringExtra(EXTRA_ROOM_ID)
if (accountViewModel == null || addressValue == null || serviceBase == null || roomId == null) { val kind = intent.getIntExtra(EXTRA_KIND, com.vitorpamplona.nestsclient.NestsRoomConfig.MEETING_SPACE_KIND)
if (accountViewModel == null ||
addressValue == null ||
authBaseUrl == null ||
endpoint == null ||
hostPubkey == null ||
roomId == null
) {
// After process death the bridge is empty (the previous // After process death the bridge is empty (the previous
// process's AccountViewModel is gone). Bounce the user back to // process's AccountViewModel is gone). Bounce the user back to
// MainActivity so they land on the lobby instead of a black // MainActivity so they land on the lobby instead of a black
@@ -138,8 +147,14 @@ class AudioRoomActivity : AppCompatActivity() {
AmethystTheme { AmethystTheme {
AudioRoomActivityContent( AudioRoomActivityContent(
addressValue = addressValue, addressValue = addressValue,
serviceBase = serviceBase, room =
roomId = roomId, com.vitorpamplona.nestsclient.NestsRoomConfig(
authBaseUrl = authBaseUrl,
endpoint = endpoint,
hostPubkey = hostPubkey,
roomId = roomId,
kind = kind,
),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
isInPipMode = isInPipMode.value, isInPipMode = isInPipMode.value,
onMuteState = { muted -> onMuteState = { muted ->
@@ -237,8 +252,11 @@ class AudioRoomActivity : AppCompatActivity() {
companion object { companion object {
const val EXTRA_ADDRESS = "com.vitorpamplona.amethyst.AUDIO_ROOM_ADDRESS" const val EXTRA_ADDRESS = "com.vitorpamplona.amethyst.AUDIO_ROOM_ADDRESS"
const val EXTRA_SERVICE_BASE = "com.vitorpamplona.amethyst.AUDIO_ROOM_SERVICE_BASE" const val EXTRA_AUTH_BASE_URL = "com.vitorpamplona.amethyst.AUDIO_ROOM_AUTH_BASE_URL"
const val EXTRA_ENDPOINT = "com.vitorpamplona.amethyst.AUDIO_ROOM_ENDPOINT"
const val EXTRA_HOST_PUBKEY = "com.vitorpamplona.amethyst.AUDIO_ROOM_HOST_PUBKEY"
const val EXTRA_ROOM_ID = "com.vitorpamplona.amethyst.AUDIO_ROOM_ROOM_ID" const val EXTRA_ROOM_ID = "com.vitorpamplona.amethyst.AUDIO_ROOM_ROOM_ID"
const val EXTRA_KIND = "com.vitorpamplona.amethyst.AUDIO_ROOM_KIND"
private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.AUDIO_ROOM_PIP_MUTE" private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.AUDIO_ROOM_PIP_MUTE"
private const val ACTION_PIP_LEAVE = "com.vitorpamplona.amethyst.AUDIO_ROOM_PIP_LEAVE" private const val ACTION_PIP_LEAVE = "com.vitorpamplona.amethyst.AUDIO_ROOM_PIP_LEAVE"
private const val PIP_MUTE_REQ = 0x6A001 private const val PIP_MUTE_REQ = 0x6A001
@@ -247,15 +265,21 @@ class AudioRoomActivity : AppCompatActivity() {
fun launch( fun launch(
context: Context, context: Context,
addressValue: String, addressValue: String,
serviceBase: String, authBaseUrl: String,
endpoint: String,
hostPubkey: String,
roomId: String, roomId: String,
kind: Int,
) { ) {
context.startActivity( context.startActivity(
Intent(context, AudioRoomActivity::class.java).apply { Intent(context, AudioRoomActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(EXTRA_ADDRESS, addressValue) putExtra(EXTRA_ADDRESS, addressValue)
putExtra(EXTRA_SERVICE_BASE, serviceBase) putExtra(EXTRA_AUTH_BASE_URL, authBaseUrl)
putExtra(EXTRA_ENDPOINT, endpoint)
putExtra(EXTRA_HOST_PUBKEY, hostPubkey)
putExtra(EXTRA_ROOM_ID, roomId) putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_KIND, kind)
}, },
) )
} }
@@ -60,8 +60,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
internal fun AudioRoomActivityContent( internal fun AudioRoomActivityContent(
addressValue: String, addressValue: String,
serviceBase: String, room: com.vitorpamplona.nestsclient.NestsRoomConfig,
roomId: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isInPipMode: Boolean, isInPipMode: Boolean,
onMuteState: (Boolean) -> Unit, onMuteState: (Boolean) -> Unit,
@@ -82,8 +81,7 @@ internal fun AudioRoomActivityContent(
val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote
AudioRoomActivityBody( AudioRoomActivityBody(
event = event, event = event,
serviceBase = serviceBase, room = room,
roomId = roomId,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
isInPipMode = isInPipMode, isInPipMode = isInPipMode,
onMuteState = onMuteState, onMuteState = onMuteState,
@@ -97,8 +95,7 @@ internal fun AudioRoomActivityContent(
@Composable @Composable
private fun AudioRoomActivityBody( private fun AudioRoomActivityBody(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
serviceBase: String, room: com.vitorpamplona.nestsclient.NestsRoomConfig,
roomId: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isInPipMode: Boolean, isInPipMode: Boolean,
onMuteState: (Boolean) -> Unit, onMuteState: (Boolean) -> Unit,
@@ -124,13 +121,12 @@ private fun AudioRoomActivityBody(
val viewModel: AudioRoomViewModel = val viewModel: AudioRoomViewModel =
viewModel( viewModel(
key = "$serviceBase|$roomId", key = "${room.authBaseUrl}|${room.roomId}",
factory = factory =
remember(serviceBase, roomId, signer) { remember(room, signer) {
AudioRoomViewModelFactory( AudioRoomViewModelFactory(
signer = signer, signer = signer,
serviceBase = serviceBase, room = room,
roomId = roomId,
) )
}, },
) )
@@ -74,11 +74,17 @@ private fun AudioRoomJoinCardContent(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val serviceBase = event.service() val serviceBase = event.service()
val endpoint = event.endpoint()
val roomId = event.address().dTag val roomId = event.address().dTag
if (serviceBase.isNullOrBlank() || roomId.isBlank()) return // Need the auth base, MoQ endpoint, and the room creator's pubkey to
// mint a JWT scoped to this namespace. Skip rendering Join when any
// are missing — those rooms aren't joinable on the audio plane.
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
val context = LocalContext.current val context = LocalContext.current
val addressValue = remember(event) { event.address().toValue() } val addressValue = remember(event) { event.address().toValue() }
val hostPubkey = event.pubKey
val kind = event.kind
Card( Card(
modifier = Modifier.fillMaxWidth().padding(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp),
@@ -106,8 +112,11 @@ private fun AudioRoomJoinCardContent(
AudioRoomActivity.launch( AudioRoomActivity.launch(
context = context, context = context,
addressValue = addressValue, addressValue = addressValue,
serviceBase = serviceBase, authBaseUrl = serviceBase,
endpoint = endpoint,
hostPubkey = hostPubkey,
roomId = roomId, roomId = roomId,
kind = kind,
) )
}) { }) {
Text(stringRes(R.string.audio_room_join)) Text(stringRes(R.string.audio_room_join))
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.OkHttpNestsClient import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.nestsclient.audio.AudioRecordCapture import com.vitorpamplona.nestsclient.audio.AudioRecordCapture
import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer
@@ -40,8 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
*/ */
internal class AudioRoomViewModelFactory( internal class AudioRoomViewModelFactory(
private val signer: NostrSigner, private val signer: NostrSigner,
private val serviceBase: String, private val room: NestsRoomConfig,
private val roomId: String,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
@@ -51,8 +51,7 @@ internal class AudioRoomViewModelFactory(
decoderFactory = { MediaCodecOpusDecoder() }, decoderFactory = { MediaCodecOpusDecoder() },
playerFactory = { AudioTrackPlayer() }, playerFactory = { AudioTrackPlayer() },
signer = signer, signer = signer,
serviceBase = serviceBase, room = room,
roomId = roomId,
captureFactory = { AudioRecordCapture() }, captureFactory = { AudioRecordCapture() },
encoderFactory = { MediaCodecOpusEncoder() }, encoderFactory = { MediaCodecOpusEncoder() },
) as T ) as T
@@ -28,6 +28,7 @@ import com.vitorpamplona.nestsclient.BroadcastHandle
import com.vitorpamplona.nestsclient.NestsClient import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsListener import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.NestsSpeaker import com.vitorpamplona.nestsclient.NestsSpeaker
import com.vitorpamplona.nestsclient.NestsSpeakerState import com.vitorpamplona.nestsclient.NestsSpeakerState
import com.vitorpamplona.nestsclient.audio.AudioCapture import com.vitorpamplona.nestsclient.audio.AudioCapture
@@ -95,8 +96,7 @@ class AudioRoomViewModel(
private val decoderFactory: () -> OpusDecoder, private val decoderFactory: () -> OpusDecoder,
private val playerFactory: () -> AudioPlayer, private val playerFactory: () -> AudioPlayer,
private val signer: NostrSigner, private val signer: NostrSigner,
private val serviceBase: String, private val room: NestsRoomConfig,
private val roomId: String,
// Speaker-side audio capture/encode actuals. Optional — desktop and // Speaker-side audio capture/encode actuals. Optional — desktop and
// listener-only callers pass null and the speaker UI hides the talk // listener-only callers pass null and the speaker UI hides the talk
// button. Android passes `{ AudioRecordCapture() }` / // button. Android passes `{ AudioRecordCapture() }` /
@@ -240,8 +240,7 @@ class AudioRoomViewModel(
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = viewModelScope, scope = viewModelScope,
serviceBase = serviceBase, room = room,
roomId = roomId,
signer = signer, signer = signer,
speakerPubkeyHex = speakerPubkeyHex, speakerPubkeyHex = speakerPubkeyHex,
captureFactory = captureFactory!!, captureFactory = captureFactory!!,
@@ -486,8 +485,7 @@ class AudioRoomViewModel(
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = viewModelScope, scope = viewModelScope,
serviceBase = serviceBase, room = room,
roomId = roomId,
signer = signer, signer = signer,
) )
if (closed) { if (closed) {
@@ -844,20 +842,18 @@ fun interface NestsListenerConnector {
httpClient: NestsClient, httpClient: NestsClient,
transport: WebTransportFactory, transport: WebTransportFactory,
scope: CoroutineScope, scope: CoroutineScope,
serviceBase: String, room: NestsRoomConfig,
roomId: String,
signer: NostrSigner, signer: NostrSigner,
): NestsListener ): NestsListener
} }
private val DefaultNestsListenerConnector = private val DefaultNestsListenerConnector =
NestsListenerConnector { httpClient, transport, scope, serviceBase, roomId, signer -> NestsListenerConnector { httpClient, transport, scope, room, signer ->
connectNestsListener( connectNestsListener(
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = scope, scope = scope,
serviceBase = serviceBase, room = room,
roomId = roomId,
signer = signer, signer = signer,
) )
} }
@@ -868,8 +864,7 @@ fun interface NestsSpeakerConnector {
httpClient: NestsClient, httpClient: NestsClient,
transport: WebTransportFactory, transport: WebTransportFactory,
scope: CoroutineScope, scope: CoroutineScope,
serviceBase: String, room: NestsRoomConfig,
roomId: String,
signer: NostrSigner, signer: NostrSigner,
speakerPubkeyHex: String, speakerPubkeyHex: String,
captureFactory: () -> AudioCapture, captureFactory: () -> AudioCapture,
@@ -878,13 +873,12 @@ fun interface NestsSpeakerConnector {
} }
private val DefaultNestsSpeakerConnector = private val DefaultNestsSpeakerConnector =
NestsSpeakerConnector { httpClient, transport, scope, serviceBase, roomId, signer, pubkey, capF, encF -> NestsSpeakerConnector { httpClient, transport, scope, room, signer, pubkey, capF, encF ->
connectNestsSpeaker( connectNestsSpeaker(
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = scope, scope = scope,
serviceBase = serviceBase, room = room,
roomId = roomId,
signer = signer, signer = signer,
speakerPubkeyHex = pubkey, speakerPubkeyHex = pubkey,
captureFactory = capF, captureFactory = capF,
@@ -24,7 +24,7 @@ import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsException import com.vitorpamplona.nestsclient.NestsException
import com.vitorpamplona.nestsclient.NestsListener import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.NestsRoomInfo import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.audio.AudioPlayer import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.moq.SubscribeHandle import com.vitorpamplona.nestsclient.moq.SubscribeHandle
@@ -93,7 +93,7 @@ class AudioRoomViewModelTest {
// Connector resolves with the listener; it's still Idle until we // Connector resolves with the listener; it's still Idle until we
// emit something. Drive it Connected directly. // emit something. Drive it Connected directly.
fakeListener.emit(NestsListenerState.Connected(roomInfo = ROOM_INFO, negotiatedMoqVersion = 0xff000011)) fakeListener.emit(NestsListenerState.Connected(room = ROOM_CONFIG, negotiatedMoqVersion = 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection) assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
} }
@@ -161,7 +161,7 @@ class AudioRoomViewModelTest {
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
vm.connect() vm.connect()
fakeListener.emit(NestsListenerState.Connected(ROOM_INFO, 0xff000011)) fakeListener.emit(NestsListenerState.Connected(ROOM_CONFIG, 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection) assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
vm.disconnect() vm.disconnect()
@@ -195,7 +195,7 @@ class AudioRoomViewModelTest {
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
vm.connect() vm.connect()
fakeListener.emit(NestsListenerState.Connected(ROOM_INFO, 0xff000011)) fakeListener.emit(NestsListenerState.Connected(ROOM_CONFIG, 0xff000011))
// Speaking-now is empty until an object arrives — exercising the // Speaking-now is empty until an object arrives — exercising the
// timeout-based clearing requires a live SubscribeHandle, which is // timeout-based clearing requires a live SubscribeHandle, which is
// covered in nestsClient's pipe tests. Here we just verify the // covered in nestsClient's pipe tests. Here we just verify the
@@ -219,10 +219,9 @@ class AudioRoomViewModelTest {
decoderFactory = { NoopOpusDecoder }, decoderFactory = { NoopOpusDecoder },
playerFactory = { NoopAudioPlayer() }, playerFactory = { NoopAudioPlayer() },
signer = NoopSigner, signer = NoopSigner,
serviceBase = "https://example.test/api/v1/nests", room = ROOM_CONFIG,
roomId = "test-room",
connector = connector =
NestsListenerConnector { _, _, scope, _, _, _ -> NestsListenerConnector { _, _, scope, _, _ ->
connect(scope) connect(scope)
}, },
// Wire to the test's backgroundScope so close calls run during // Wire to the test's backgroundScope so close calls run during
@@ -249,11 +248,11 @@ class AudioRoomViewModelTest {
} }
private object NoopNestsClient : NestsClient { private object NoopNestsClient : NestsClient {
override suspend fun resolveRoom( override suspend fun mintToken(
serviceBase: String, room: NestsRoomConfig,
roomId: String, publish: Boolean,
signer: NostrSigner, signer: NostrSigner,
): NestsRoomInfo = error("resolveRoom not used (connector seam bypasses it)") ): String = error("mintToken not used (connector seam bypasses it)")
} }
private object NoopWebTransportFactory : WebTransportFactory { private object NoopWebTransportFactory : WebTransportFactory {
@@ -318,6 +317,12 @@ class AudioRoomViewModelTest {
} }
companion object { companion object {
private val ROOM_INFO = NestsRoomInfo(endpoint = "https://relay.example.test/moq") private val ROOM_CONFIG =
NestsRoomConfig(
authBaseUrl = "https://relay.example.test/api/v1/nests",
endpoint = "https://relay.example.test/moq",
hostPubkey = "0".repeat(64),
roomId = "test-room",
)
} }
} }
@@ -35,21 +35,25 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
*/ */
interface NestsClient { interface NestsClient {
/** /**
* Fetch [NestsRoomInfo] for a specific room. * Mint a JWT scoped to one MoQ namespace. Posts a NIP-98-signed
* request to `<authBase>/auth` per the nostrnests reference server
* (`moq-auth/src/index.ts`); the returned token is the bearer the
* MoQ relay validates against the auth sidecar's JWKS endpoint.
* *
* @param serviceBase value of the NIP-53 kind 30312 `service` tag * @param room per-room config carrying authBase, host pubkey, room id.
* (e.g. `https://nostrnests.com/api/v1/nests`) * @param publish `true` if the caller wants publish rights for their
* @param roomId the event's `d` tag * own pubkey under this namespace; `false` for listen-only.
* @param signer signs the NIP-98 auth event that the server uses to verify * @param signer signs the NIP-98 auth event. The server verifies the
* the caller owns the pubkey it claims * event binds to this exact (url, method, body-hash) tuple, so
* @throws NestsException on transport errors, non-2xx responses, or malformed * the JWT cannot be replayed against a different request.
* JSON * @throws NestsException on transport errors, non-2xx responses, or
* malformed JSON.
*/ */
suspend fun resolveRoom( suspend fun mintToken(
serviceBase: String, room: NestsRoomConfig,
roomId: String, publish: Boolean,
signer: NostrSigner, signer: NostrSigner,
): NestsRoomInfo ): String
} }
/** /**
@@ -36,10 +36,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
* Walk the full join-as-listener handshake against a nests-compatible audio * Walk the full join-as-listener handshake against a nests-compatible audio
* server: * server:
* *
* 1. Resolve the room — POST/GET `<serviceBase>/<roomId>` with NIP-98 auth, * 1. Mint a JWT — POST `<authBase>/auth` with NIP-98 + namespace body
* returning [NestsRoomInfo] (the MoQ endpoint + bearer token). * (see [NestsClient.mintToken]).
* 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession] * 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession]
* against the endpoint via [transport]. * against the [room.endpoint] via [transport], passing the JWT as the
* bearer token.
* 3. Run the MoQ SETUP handshake. * 3. Run the MoQ SETUP handshake.
* *
* The returned [NestsListener] is in state [NestsListenerState.Connected]; * The returned [NestsListener] is in state [NestsListenerState.Connected];
@@ -47,7 +48,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
* [NestsListenerState.Failed] with the underlying cause attached and the * [NestsListenerState.Failed] with the underlying cause attached and the
* transport torn down. * transport torn down.
* *
* @param signer NIP-98 signer for the resolveRoom HTTP call. * @param room per-room config built from the NIP-53 kind 30312 event by
* the caller (UI / VM).
* @param signer NIP-98 signer for the mintToken HTTP call.
* @param scope where the [MoqSession] pumps live (typically the caller's * @param scope where the [MoqSession] pumps live (typically the caller's
* ViewModel scope so they cancel when the screen leaves). * ViewModel scope so they cancel when the screen leaves).
* @param supportedMoqVersions in preference order; defaults to draft-17. * @param supportedMoqVersions in preference order; defaults to draft-17.
@@ -56,8 +59,7 @@ suspend fun connectNestsListener(
httpClient: NestsClient, httpClient: NestsClient,
transport: WebTransportFactory, transport: WebTransportFactory,
scope: CoroutineScope, scope: CoroutineScope,
serviceBase: String, room: NestsRoomConfig,
roomId: String,
signer: NostrSigner, signer: NostrSigner,
supportedMoqVersions: List<Long> = listOf(MoqVersion.DRAFT_17), supportedMoqVersions: List<Long> = listOf(MoqVersion.DRAFT_17),
): NestsListener { ): NestsListener {
@@ -66,11 +68,11 @@ suspend fun connectNestsListener(
NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom), NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom),
) )
val roomInfo = val token =
try { try {
httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer) httpClient.mintToken(room = room, publish = false, signer = signer)
} catch (t: NestsException) { } catch (t: NestsException) {
state.value = NestsListenerState.Failed("Room resolution failed: ${t.message}", t) state.value = NestsListenerState.Failed("Auth failed: ${t.message}", t)
return failedListener(state) return failedListener(state)
} }
@@ -78,11 +80,11 @@ suspend fun connectNestsListener(
val (authority, path) = val (authority, path) =
try { try {
parseEndpoint(roomInfo.endpoint) parseEndpoint(room.endpoint)
} catch (t: Throwable) { } catch (t: Throwable) {
state.value = state.value =
NestsListenerState.Failed( NestsListenerState.Failed(
"Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}", "Malformed MoQ endpoint URL '${room.endpoint}': ${t.message}",
t, t,
) )
return failedListener(state) return failedListener(state)
@@ -90,7 +92,7 @@ suspend fun connectNestsListener(
val webTransport = val webTransport =
try { try {
transport.connect(authority = authority, path = path, bearerToken = roomInfo.token) transport.connect(authority = authority, path = path, bearerToken = token)
} catch (t: WebTransportException) { } catch (t: WebTransportException) {
state.value = state.value =
NestsListenerState.Failed( NestsListenerState.Failed(
@@ -118,10 +120,15 @@ suspend fun connectNestsListener(
return failedListener(state) return failedListener(state)
} }
state.value = NestsListenerState.Connected(roomInfo, negotiatedVersion) state.value = NestsListenerState.Connected(room, negotiatedVersion)
return DefaultNestsListener( return DefaultNestsListener(
session = moq, session = moq,
roomNamespace = TrackNamespace.of("nests", roomId), // moq-auth's JWT claim is `root: "<moqNamespace>"` — the simplest
// wire-level mapping is a 1-segment TrackNamespace whose only
// element is that exact string. Phase-3 interop test will
// confirm; if the real relay expects a multi-segment tuple
// (e.g. split on `/` or `:`), adjust here.
roomNamespace = TrackNamespace.of(room.moqNamespace()),
mutableState = state, mutableState = state,
) )
} }
@@ -160,8 +167,7 @@ suspend fun connectNestsSpeaker(
httpClient: NestsClient, httpClient: NestsClient,
transport: WebTransportFactory, transport: WebTransportFactory,
scope: CoroutineScope, scope: CoroutineScope,
serviceBase: String, room: NestsRoomConfig,
roomId: String,
signer: NostrSigner, signer: NostrSigner,
speakerPubkeyHex: String, speakerPubkeyHex: String,
captureFactory: () -> AudioCapture, captureFactory: () -> AudioCapture,
@@ -173,11 +179,11 @@ suspend fun connectNestsSpeaker(
NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.ResolvingRoom), NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.ResolvingRoom),
) )
val roomInfo = val token =
try { try {
httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer) httpClient.mintToken(room = room, publish = true, signer = signer)
} catch (t: NestsException) { } catch (t: NestsException) {
state.value = NestsSpeakerState.Failed("Room resolution failed: ${t.message}", t) state.value = NestsSpeakerState.Failed("Auth failed: ${t.message}", t)
return failedSpeaker(state) return failedSpeaker(state)
} }
@@ -185,11 +191,11 @@ suspend fun connectNestsSpeaker(
val (authority, path) = val (authority, path) =
try { try {
parseEndpoint(roomInfo.endpoint) parseEndpoint(room.endpoint)
} catch (t: Throwable) { } catch (t: Throwable) {
state.value = state.value =
NestsSpeakerState.Failed( NestsSpeakerState.Failed(
"Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}", "Malformed MoQ endpoint URL '${room.endpoint}': ${t.message}",
t, t,
) )
return failedSpeaker(state) return failedSpeaker(state)
@@ -197,7 +203,7 @@ suspend fun connectNestsSpeaker(
val webTransport = val webTransport =
try { try {
transport.connect(authority = authority, path = path, bearerToken = roomInfo.token) transport.connect(authority = authority, path = path, bearerToken = token)
} catch (t: WebTransportException) { } catch (t: WebTransportException) {
state.value = state.value =
NestsSpeakerState.Failed( NestsSpeakerState.Failed(
@@ -225,10 +231,11 @@ suspend fun connectNestsSpeaker(
return failedSpeaker(state) return failedSpeaker(state)
} }
state.value = NestsSpeakerState.Connected(roomInfo, negotiatedVersion) state.value = NestsSpeakerState.Connected(room, negotiatedVersion)
return DefaultNestsSpeaker( return DefaultNestsSpeaker(
session = moq, session = moq,
roomNamespace = TrackNamespace.of("nests", roomId), // Same single-segment shape as the listener path; see comment there.
roomNamespace = TrackNamespace.of(room.moqNamespace()),
speakerTrackName = speakerPubkeyHex.encodeToByteArray(), speakerTrackName = speakerPubkeyHex.encodeToByteArray(),
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
@@ -82,9 +82,9 @@ sealed class NestsListenerState {
} }
} }
/** Connection is live. [roomInfo] reflects the resolved server metadata. */ /** Connection is live. [room] reflects the (auth, endpoint, room) we connected to. */
data class Connected( data class Connected(
val roomInfo: NestsRoomInfo, val room: NestsRoomConfig,
val negotiatedMoqVersion: Long, val negotiatedMoqVersion: Long,
) : NestsListenerState() ) : NestsListenerState()
@@ -20,30 +20,51 @@
*/ */
package com.vitorpamplona.nestsclient package com.vitorpamplona.nestsclient
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
/** /**
* Information returned by a nests audio-room backend when a client * Per-room configuration that orchestration needs to connect. Built from
* authenticates against `<service>/api/v1/nests/<roomId>`. * the NIP-53 kind 30312 [MeetingSpaceEvent] by the caller (UI / VM)
* before invoking `connectNestsListener` / `connectNestsSpeaker`:
* *
* Field names mirror the nests reference server payload: * - [authBaseUrl] the event's `service` tag (e.g. `https://nostrnests.com/api/v1/nests`).
* ``` * Note: the real moq-auth API is rooted at this base; the client posts
* { "endpoint": "...", "token": "...", "codec": "opus", "sample_rate": 48000 } * to `<authBaseUrl>/auth` to mint a JWT.
* ``` * - [endpoint] the event's `endpoint` tag (e.g. `https://relay.nostrnests.com:4443/anon`).
* All fields except [endpoint] are optional so the client can negotiate with * The MoQ relay's WebTransport URL.
* alternate nests implementations that omit codec/token metadata. * - [hostPubkey] the event author's pubkey. Goes into the
* [NestsAuth.MOQ_NAMESPACE_PREFIX] to scope the JWT to this room.
* - [roomId] the event's `d` tag.
* - [kind] the NIP-53 event kind (30312 for meeting spaces).
*/
data class NestsRoomConfig(
val authBaseUrl: String,
val endpoint: String,
val hostPubkey: String,
val roomId: String,
val kind: Int = MEETING_SPACE_KIND,
) {
/**
* MoQ namespace string for this room, in the format moq-auth expects:
* `nests/<kind>:<host_pubkey_hex>:<roomId>`.
*/
fun moqNamespace(): String = "nests/$kind:$hostPubkey:$roomId"
companion object {
const val MEETING_SPACE_KIND: Int = 30312
}
}
/**
* Response shape from `POST <authBase>/auth`. The reference server
* (`nostrnests/nests/moq-auth/src/index.ts`) returns `{"token":"<jwt>"}`
* that JWT is then passed as the WebTransport bearer token to the
* MoQ relay.
*/ */
@Serializable @Serializable
data class NestsRoomInfo( data class NestsTokenResponse(
val endpoint: String, val token: String,
val token: String? = null,
val codec: String? = null,
@SerialName("sample_rate") val sampleRate: Int? = null,
val transport: String? = null,
val extra: Map<String, JsonElement> = emptyMap(),
) { ) {
companion object { companion object {
private val json = private val json =
@@ -52,23 +73,15 @@ data class NestsRoomInfo(
explicitNulls = false explicitNulls = false
} }
fun parse(body: String): NestsRoomInfo = json.decodeFromString(serializer(), body) fun parse(body: String): NestsTokenResponse = json.decodeFromString(serializer(), body)
} }
} }
/** /**
* Build the URL a client should call to resolve [NestsRoomInfo] for a specific * Build the auth URL for a given service base. Trims trailing slashes
* room. [serviceBase] comes from the `service` tag of the NIP-53 kind 30312 * and appends `/auth`.
* event; [roomId] is the event's `d` tag.
* *
* Example: `https://nostrnests.com/api/v1/nests` + `abc-123` * Example: `https://nostrnests.com/api/v1/nests` →
* `https://nostrnests.com/api/v1/nests/abc-123`. * `https://nostrnests.com/api/v1/nests/auth`.
*/ */
fun nestsRoomInfoUrl( fun nestsAuthUrl(authBase: String): String = authBase.trimEnd('/') + "/auth"
serviceBase: String,
roomId: String,
): String {
val trimmed = serviceBase.trimEnd('/')
val encoded = roomId.trim()
return "$trimmed/$encoded"
}
@@ -95,13 +95,13 @@ sealed class NestsSpeakerState {
/** Connection live; ready for [NestsSpeaker.startBroadcasting]. */ /** Connection live; ready for [NestsSpeaker.startBroadcasting]. */
data class Connected( data class Connected(
val roomInfo: NestsRoomInfo, val room: NestsRoomConfig,
val negotiatedMoqVersion: Long, val negotiatedMoqVersion: Long,
) : NestsSpeakerState() ) : NestsSpeakerState()
/** Currently announcing + emitting OBJECT_DATAGRAMs for our track. */ /** Currently announcing + emitting OBJECT_DATAGRAMs for our track. */
data class Broadcasting( data class Broadcasting(
val roomInfo: NestsRoomInfo, val room: NestsRoomConfig,
val negotiatedMoqVersion: Long, val negotiatedMoqVersion: Long,
val isMuted: Boolean, val isMuted: Boolean,
) : NestsSpeakerState() ) : NestsSpeakerState()
@@ -161,7 +161,7 @@ class DefaultNestsSpeaker internal constructor(
broadcaster.start() broadcaster.start()
mutableState.value = mutableState.value =
NestsSpeakerState.Broadcasting( NestsSpeakerState.Broadcasting(
roomInfo = current.roomInfo, room = current.room,
negotiatedMoqVersion = current.negotiatedMoqVersion, negotiatedMoqVersion = current.negotiatedMoqVersion,
isMuted = false, isMuted = false,
) )
@@ -187,7 +187,7 @@ class DefaultNestsSpeaker internal constructor(
val current = mutableState.value val current = mutableState.value
if (current is NestsSpeakerState.Broadcasting) { if (current is NestsSpeakerState.Broadcasting) {
mutableState.value = mutableState.value =
NestsSpeakerState.Connected(current.roomInfo, current.negotiatedMoqVersion) NestsSpeakerState.Connected(current.room, current.negotiatedMoqVersion)
} }
} }
@@ -41,17 +41,19 @@ import kotlin.test.assertTrue
import kotlin.test.fail import kotlin.test.fail
class NestsConnectTest { class NestsConnectTest {
private val room =
NestsRoomConfig(
authBaseUrl = "https://relay.example.com/api/v1/nests",
endpoint = "https://relay.example.com/moq",
hostPubkey = "0".repeat(64),
roomId = "abc",
)
@Test @Test
fun connect_walks_resolveRoom_then_transport_then_moq_handshake() = fun connect_walks_mintToken_then_transport_then_moq_handshake() =
runTest { runTest {
val (clientSide, serverSide) = FakeWebTransport.pair() val (clientSide, serverSide) = FakeWebTransport.pair()
val httpClient = val httpClient = FakeNestsClient(token = "tok-abc")
FakeNestsClient(
NestsRoomInfo(
endpoint = "https://relay.example.com/moq",
token = "tok-abc",
),
)
val transport = ConstantWebTransportFactory(clientSide) val transport = ConstantWebTransportFactory(clientSide)
// Server-side raw peer answers SETUP. // Server-side raw peer answers SETUP.
@@ -67,31 +69,29 @@ class NestsConnectTest {
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = this, scope = this,
serviceBase = "https://relay.example.com/api/v1/nests", room = room,
roomId = "abc",
signer = NostrSignerInternal(KeyPair()), signer = NostrSignerInternal(KeyPair()),
) )
server.await() server.await()
val connected = assertIs<NestsListenerState.Connected>(listener.state.value) val connected = assertIs<NestsListenerState.Connected>(listener.state.value)
assertEquals("https://relay.example.com/moq", connected.roomInfo.endpoint) assertEquals(room, connected.room)
assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion) assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion)
assertEquals("relay.example.com", transport.lastConnectedAuthority) assertEquals("relay.example.com", transport.lastConnectedAuthority)
assertEquals("/moq", transport.lastConnectedPath) assertEquals("/moq", transport.lastConnectedPath)
assertEquals("tok-abc", transport.lastBearer) assertEquals("tok-abc", transport.lastBearer)
assertEquals(false, httpClient.lastPublishFlag, "listener mints with publish=false")
listener.close() listener.close()
assertIs<NestsListenerState.Closed>(listener.state.value) assertIs<NestsListenerState.Closed>(listener.state.value)
} }
@Test @Test
fun resolveRoom_failure_short_circuits_to_Failed() = fun mintToken_failure_short_circuits_to_Failed() =
runTest { runTest {
val httpClient = val httpClient = ThrowingNestsClient(NestsException("server returned 500", status = 500))
ThrowingNestsClient(
NestsException("server returned 500", status = 500),
)
val transport = NeverConnectFactory() val transport = NeverConnectFactory()
val listener = val listener =
@@ -99,13 +99,12 @@ class NestsConnectTest {
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = this, scope = this,
serviceBase = "https://relay.example.com/api/v1/nests", room = room,
roomId = "abc",
signer = NostrSignerInternal(KeyPair()), signer = NostrSignerInternal(KeyPair()),
) )
val failed = assertIs<NestsListenerState.Failed>(listener.state.value) val failed = assertIs<NestsListenerState.Failed>(listener.state.value)
assertTrue("Room resolution failed" in failed.reason) assertTrue("Auth failed" in failed.reason)
assertTrue("500" in failed.reason || (failed.cause as? NestsException)?.status == 500) assertTrue("500" in failed.reason || (failed.cause as? NestsException)?.status == 500)
assertEquals(0, transport.connectCallCount, "transport must not be reached") assertEquals(0, transport.connectCallCount, "transport must not be reached")
} }
@@ -113,8 +112,7 @@ class NestsConnectTest {
@Test @Test
fun transport_handshake_failure_short_circuits_to_Failed() = fun transport_handshake_failure_short_circuits_to_Failed() =
runTest { runTest {
val httpClient = val httpClient = FakeNestsClient(token = "tok")
FakeNestsClient(NestsRoomInfo(endpoint = "https://relay.example.com/moq"))
val transport = val transport =
ThrowingTransportFactory( ThrowingTransportFactory(
WebTransportException( WebTransportException(
@@ -128,8 +126,7 @@ class NestsConnectTest {
httpClient = httpClient, httpClient = httpClient,
transport = transport, transport = transport,
scope = this, scope = this,
serviceBase = "https://relay.example.com/api/v1/nests", room = room,
roomId = "abc",
signer = NostrSignerInternal(KeyPair()), signer = NostrSignerInternal(KeyPair()),
) )
@@ -140,16 +137,13 @@ class NestsConnectTest {
@Test @Test
fun malformed_endpoint_url_short_circuits_to_Failed() = fun malformed_endpoint_url_short_circuits_to_Failed() =
runTest { runTest {
val httpClient = val badRoom = room.copy(endpoint = "not-a-url")
FakeNestsClient(NestsRoomInfo(endpoint = "not-a-url"))
val listener = val listener =
connectNestsListener( connectNestsListener(
httpClient = httpClient, httpClient = FakeNestsClient(token = "tok"),
transport = NeverConnectFactory(), transport = NeverConnectFactory(),
scope = this, scope = this,
serviceBase = "https://relay.example.com/api/v1/nests", room = badRoom,
roomId = "abc",
signer = NostrSignerInternal(KeyPair()), signer = NostrSignerInternal(KeyPair()),
) )
@@ -180,23 +174,29 @@ class NestsConnectTest {
// ---------------------------------------------------------- fakes // ---------------------------------------------------------- fakes
private class FakeNestsClient( private class FakeNestsClient(
private val info: NestsRoomInfo, private val token: String,
) : NestsClient { ) : NestsClient {
override suspend fun resolveRoom( var lastPublishFlag: Boolean? = null
serviceBase: String, private set
roomId: String,
override suspend fun mintToken(
room: NestsRoomConfig,
publish: Boolean,
signer: NostrSigner, signer: NostrSigner,
): NestsRoomInfo = info ): String {
lastPublishFlag = publish
return token
}
} }
private class ThrowingNestsClient( private class ThrowingNestsClient(
private val toThrow: NestsException, private val toThrow: NestsException,
) : NestsClient { ) : NestsClient {
override suspend fun resolveRoom( override suspend fun mintToken(
serviceBase: String, room: NestsRoomConfig,
roomId: String, publish: Boolean,
signer: NostrSigner, signer: NostrSigner,
): NestsRoomInfo = throw toThrow ): String = throw toThrow
} }
private class ConstantWebTransportFactory( private class ConstantWebTransportFactory(
@@ -0,0 +1,76 @@
/*
* 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
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class NestsRoomConfigTest {
@Test
fun moq_namespace_uses_kind_host_pubkey_room_id_format() {
val cfg =
NestsRoomConfig(
authBaseUrl = "https://nostrnests.com/api/v1/nests",
endpoint = "https://relay.nostrnests.com:4443/anon",
hostPubkey = "0".repeat(64),
roomId = "room-abc",
)
// Matches moq-auth's NAMESPACE_REGEX: nests/<kind>:<hex64>:<room>
assertEquals(
"nests/30312:${"0".repeat(64)}:room-abc",
cfg.moqNamespace(),
)
}
@Test
fun parses_token_response_from_moq_auth() {
val r = NestsTokenResponse.parse("""{"token":"abc.def.ghi"}""")
assertEquals("abc.def.ghi", r.token)
}
@Test
fun ignores_unknown_response_fields() {
val r = NestsTokenResponse.parse("""{"token":"t","exp":1234567,"future_knob":"x"}""")
assertEquals("t", r.token)
}
@Test
fun rejects_missing_token_field() {
assertFailsWith<Exception> { NestsTokenResponse.parse("""{"endpoint":"x"}""") }
}
@Test
fun builds_auth_url_from_service_base() {
assertEquals(
"https://nostrnests.com/api/v1/nests/auth",
nestsAuthUrl("https://nostrnests.com/api/v1/nests"),
)
}
@Test
fun trims_trailing_slash_from_service_base() {
assertEquals(
"https://nostrnests.com/api/v1/nests/auth",
nestsAuthUrl("https://nostrnests.com/api/v1/nests/"),
)
}
}
@@ -1,90 +0,0 @@
/*
* 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
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class NestsRoomInfoTest {
@Test
fun parses_full_payload() {
val info =
NestsRoomInfo.parse(
"""{"endpoint":"https://relay.example.com/moq","token":"abc.def","codec":"opus","sample_rate":48000,"transport":"webtransport"}""",
)
assertEquals("https://relay.example.com/moq", info.endpoint)
assertEquals("abc.def", info.token)
assertEquals("opus", info.codec)
assertEquals(48000, info.sampleRate)
assertEquals("webtransport", info.transport)
}
@Test
fun tolerates_minimal_payload() {
val info = NestsRoomInfo.parse("""{"endpoint":"https://r.example.com/moq"}""")
assertEquals("https://r.example.com/moq", info.endpoint)
assertNull(info.token)
assertNull(info.codec)
assertNull(info.sampleRate)
}
@Test
fun ignores_unknown_fields() {
val info =
NestsRoomInfo.parse(
"""{"endpoint":"https://r.example.com/moq","token":"t","future_knob":"future_value"}""",
)
assertEquals("t", info.token)
}
@Test
fun rejects_missing_endpoint() {
assertFailsWith<Exception> {
NestsRoomInfo.parse("""{"token":"t"}""")
}
}
@Test
fun builds_room_info_url_from_service_and_room_id() {
assertEquals(
"https://nostrnests.com/api/v1/nests/abc-123",
nestsRoomInfoUrl("https://nostrnests.com/api/v1/nests", "abc-123"),
)
}
@Test
fun trims_trailing_slash_from_service_base() {
assertEquals(
"https://nostrnests.com/api/v1/nests/xyz",
nestsRoomInfoUrl("https://nostrnests.com/api/v1/nests/", "xyz"),
)
}
@Test
fun trims_whitespace_from_room_id() {
assertEquals(
"https://a.example.com/api/v1/nests/room",
nestsRoomInfoUrl("https://a.example.com/api/v1/nests", " room "),
)
}
}
@@ -63,7 +63,7 @@ class NestsSpeakerTest {
val state = val state =
MutableStateFlow<NestsSpeakerState>( MutableStateFlow<NestsSpeakerState>(
NestsSpeakerState.Connected( NestsSpeakerState.Connected(
roomInfo = NestsRoomInfo(endpoint = "https://relay.example/moq"), room = TEST_ROOM,
negotiatedMoqVersion = speakerSession.selectedVersion!!, negotiatedMoqVersion = speakerSession.selectedVersion!!,
), ),
) )
@@ -118,7 +118,7 @@ class NestsSpeakerTest {
val state = val state =
MutableStateFlow<NestsSpeakerState>( MutableStateFlow<NestsSpeakerState>(
NestsSpeakerState.Connected( NestsSpeakerState.Connected(
roomInfo = NestsRoomInfo(endpoint = "https://relay.example/moq"), room = TEST_ROOM,
negotiatedMoqVersion = speakerSession.selectedVersion!!, negotiatedMoqVersion = speakerSession.selectedVersion!!,
), ),
) )
@@ -167,4 +167,14 @@ class NestsSpeakerTest {
override fun release() {} override fun release() {}
} }
companion object {
private val TEST_ROOM =
NestsRoomConfig(
authBaseUrl = "https://relay.example/api/v1/nests",
endpoint = "https://relay.example/moq",
hostPubkey = "0".repeat(64),
roomId = "test-room",
)
}
} }
@@ -23,8 +23,10 @@ package com.vitorpamplona.nestsclient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
/** /**
@@ -35,19 +37,35 @@ import java.io.IOException
class OkHttpNestsClient( class OkHttpNestsClient(
private val http: OkHttpClient = OkHttpClient(), private val http: OkHttpClient = OkHttpClient(),
) : NestsClient { ) : NestsClient {
override suspend fun resolveRoom( override suspend fun mintToken(
serviceBase: String, room: NestsRoomConfig,
roomId: String, publish: Boolean,
signer: NostrSigner, signer: NostrSigner,
): NestsRoomInfo { ): String {
val url = nestsRoomInfoUrl(serviceBase, roomId) val url = nestsAuthUrl(room.authBaseUrl)
val authHeader = NestsAuth.header(signer = signer, url = url, method = "GET") val bodyJson =
buildString {
append('{')
append("\"namespace\":\"").append(room.moqNamespace()).append('"')
append(",\"publish\":").append(publish)
append('}')
}
val bodyBytes = bodyJson.encodeToByteArray()
// NIP-98 binds the signed event to (url, method, body-hash) so the
// server can reject a token replayed against a different request.
val authHeader =
NestsAuth.header(
signer = signer,
url = url,
method = "POST",
payload = bodyBytes,
)
val request = val request =
Request Request
.Builder() .Builder()
.url(url) .url(url)
.get() .post(bodyJson.toRequestBody(JSON_MEDIA_TYPE))
.header("Authorization", authHeader) .header("Authorization", authHeader)
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
@@ -59,12 +77,12 @@ class OkHttpNestsClient(
val body = response.body.string() val body = response.body.string()
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw NestsException( throw NestsException(
"nests server returned ${response.code} for $url", "nests server returned ${response.code} for $url: $body",
status = response.code, status = response.code,
) )
} }
try { try {
NestsRoomInfo.parse(body) NestsTokenResponse.parse(body).token
} catch (e: IOException) { } catch (e: IOException) {
throw NestsException("Malformed nests response from $url", e) throw NestsException("Malformed nests response from $url", e)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
@@ -75,4 +93,8 @@ class OkHttpNestsClient(
} }
} }
} }
private companion object {
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
}
} }
@@ -20,86 +20,52 @@
*/ */
package com.vitorpamplona.nestsclient.interop package com.vitorpamplona.nestsclient.interop
import com.vitorpamplona.nestsclient.NestsAuth import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.OkHttpNestsClient
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking 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.AfterClass
import org.junit.BeforeClass import org.junit.BeforeClass
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* Phase-1 interop smoke test. Brings up a real nostrnests stack (auth * Phase-1 interop smoke test. Brings up a real nostrnests stack (auth
* sidecar + MoQ relay + strfry) via Docker Compose, then exercises the * sidecar + MoQ relay + strfry) via Docker Compose, then drives the
* `/auth` endpoint with a hand-rolled NIP-98 request that matches what * production [OkHttpNestsClient] against the real `/auth` endpoint to
* the server actually expects. * mint a JWT. Validates the wire format (POST `<base>/auth` with
* * `{namespace, publish}` body + NIP-98 Authorization header, returning
* Doesn't yet use [com.vitorpamplona.nestsclient.NestsClient] * `{token}`) end-to-end.
* `OkHttpNestsClient`'s wire shape (GET `<base>/<roomId>` with no body,
* expecting `{endpoint, token}` back) doesn't match the real server
* (POST `<base>/auth` with `{namespace, publish}` body, returning just
* `{token}`). Phase 2 of this audit refactors the production client to
* match; until then this test documents the divergence on the wire.
* *
* Skipped by default set `-DnestsInterop=true` to enable. * Skipped by default set `-DnestsInterop=true` to enable.
*/ */
class NostrNestsAuthInteropTest { class NostrNestsAuthInteropTest {
@Test @Test
fun auth_endpoint_returns_jwt_for_a_well_formed_nip98_request() = fun production_OkHttpNestsClient_mints_a_jwt_against_real_moq_auth() =
runBlocking { runBlocking {
NostrNestsHarness.assumeNestsInterop() NostrNestsHarness.assumeNestsInterop()
val harness = harnessOrNull ?: return@runBlocking val harness = harnessOrNull ?: return@runBlocking
val keys = KeyPair() val signer = NostrSignerInternal(KeyPair())
val signer = NostrSignerInternal(keys) val client = OkHttpNestsClient()
val pubkeyHex = signer.pubKey val room =
NestsRoomConfig(
val authUrl = "${harness.authBaseUrl}/auth" authBaseUrl = harness.authBaseUrl,
val roomId = "interop-${System.currentTimeMillis()}" endpoint = harness.moqEndpoint,
val namespace = "nests/30312:$pubkeyHex:$roomId" hostPubkey = signer.pubKey,
val body = """{"namespace":"$namespace","publish":true}""" roomId = "interop-${System.currentTimeMillis()}",
val authHeader =
NestsAuth.header(
signer = signer,
url = authUrl,
method = "POST",
payload = body.toByteArray(),
) )
val request = val token = client.mintToken(room = room, publish = true, signer = signer)
Request
.Builder()
.url(authUrl)
.post(body.toRequestBody("application/json".toMediaType()))
.header("Authorization", authHeader)
.build()
val (status, responseBody) = // moq-auth signs JWS tokens with three base64url-encoded
http.newCall(request).execute().use { response -> // segments separated by dots.
response.code to (response.body.string()) assertTrue(token.count { it == '.' } == 2, "Expected JWT (3 segments), got: $token")
} assertTrue(token.isNotBlank(), "JWT must be non-empty")
assertEquals(
200,
status,
"POST /auth should return 200 with a valid NIP-98 + namespace; got $status: $responseBody",
)
// moq-auth's response is `{"token":"<jwt>"}` per its index.ts.
assertTrue(
responseBody.contains("\"token\":\""),
"Expected JWT in `token` field, got: $responseBody",
)
} }
companion object { companion object {
private val http = OkHttpClient()
private var harnessOrNull: NostrNestsHarness? = null private var harnessOrNull: NostrNestsHarness? = null
@BeforeClass @BeforeClass